From 7009bb9311832c08037f6d13c867514f846d3fbe Mon Sep 17 00:00:00 2001 From: Peter White <1788320+peterwhite@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:24:54 +0200 Subject: [PATCH 01/10] Drop the body-anchored cover gate from paragraph contrast text (#4573) --- .changeset/paragraph-cover-has-invalidation.md | 5 +++++ .../src/components/DocumentView/Paragraph.tsx | 17 ++++------------- .../src/components/RootLayout/globals.css | 13 +++++++++++++ 3 files changed, 22 insertions(+), 13 deletions(-) create mode 100644 .changeset/paragraph-cover-has-invalidation.md diff --git a/.changeset/paragraph-cover-has-invalidation.md b/.changeset/paragraph-cover-has-invalidation.md new file mode 100644 index 000000000..b4fa2b012 --- /dev/null +++ b/.changeset/paragraph-cover-has-invalidation.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Move paragraph block styles behind a single `paragraph` class and drop the `page-cover-background:` gate from the cover-contrast text. The gate combined with the per-paragraph `:not(:has(...))` made every DOM insertion re-style all paragraphs, which froze very long pages. diff --git a/packages/gitbook/src/components/DocumentView/Paragraph.tsx b/packages/gitbook/src/components/DocumentView/Paragraph.tsx index 1ee73ae4b..6359f0026 100644 --- a/packages/gitbook/src/components/DocumentView/Paragraph.tsx +++ b/packages/gitbook/src/components/DocumentView/Paragraph.tsx @@ -12,22 +12,13 @@ export function Paragraph(props: BlockProps) { const { block, style, ...contextProps } = props; const { context } = contextProps; - // InlineActionButtons use flex-grow to take the available width. This requires the parent to be a flex container. - const inlineButtonStyle = - 'has-[.button,input]:flex has-[.button,input]:flex-wrap has-[.button,input]:gap-2 has-[.button,input]:items-center'; - const paragraph = (

diff --git a/packages/gitbook/src/components/RootLayout/globals.css b/packages/gitbook/src/components/RootLayout/globals.css index 965381d29..c2ecfa8e7 100644 --- a/packages/gitbook/src/components/RootLayout/globals.css +++ b/packages/gitbook/src/components/RootLayout/globals.css @@ -564,6 +564,19 @@ html.dark .highlight-line.diff-deleted .highlight-line-content::before { @apply rounded-none!; } +@layer components { + .paragraph { + /* Inline action buttons grow to the available width, which needs a flex parent. */ + &:has(.button, input) { + @apply flex flex-wrap items-center gap-2; + } + + &[data-cover-aware-text]:not(:has(.button, input)) { + @apply text-contrast-cover; + } + } +} + /* Zoomable images */ html:has(.zoom-modal) { /* stylelint-disable-next-line plugin/no-unsupported-browser-features -- single-value overflow is universal; doiuse flags the whole css-overflow feature */ From 5f88f4e6803fdfb09619124d54f533426d726e84 Mon Sep 17 00:00:00 2001 From: Tomek Date: Wed, 2 Sep 2026 11:43:35 +0200 Subject: [PATCH 02/10] Improve cross-space page breadcrumbs (#4571) Co-authored-by: Claude Haiku 4.5 --- .changeset/cross-space-page-breadcrumbs.md | 5 + packages/gitbook/src/lib/references.test.ts | 246 ++++++++++++++++++++ packages/gitbook/src/lib/references.tsx | 61 ++++- packages/gitbook/src/lib/sites.test.ts | 33 +++ packages/gitbook/src/lib/sites.ts | 17 +- 5 files changed, 348 insertions(+), 14 deletions(-) create mode 100644 .changeset/cross-space-page-breadcrumbs.md diff --git a/.changeset/cross-space-page-breadcrumbs.md b/.changeset/cross-space-page-breadcrumbs.md new file mode 100644 index 000000000..d5c899290 --- /dev/null +++ b/.changeset/cross-space-page-breadcrumbs.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix breadcrumbs for cross-space page links in grouped sites. diff --git a/packages/gitbook/src/lib/references.test.ts b/packages/gitbook/src/lib/references.test.ts index e5b223cba..4342f5a78 100644 --- a/packages/gitbook/src/lib/references.test.ts +++ b/packages/gitbook/src/lib/references.test.ts @@ -491,4 +491,250 @@ describe('resolveContentRef for direct space links', () => { expect(result?.href).toBe(guideSiteSpace.urls.published!); expect(result?.active).toBe(false); }); + + function buildDocumentPage( + id: string, + title: string, + path: string, + pages: unknown[] = [] + ): RevisionPageDocument { + return { + object: 'page', + id, + type: 'document', + kind: 'sheet', + title, + path, + slug: path.split('/').pop() ?? path, + pages, + tags: [], + layout: {}, + urls: { app: `https://app.gitbook.com/page/${id}` }, + } as unknown as RevisionPageDocument; + } + + function buildRevision(pages: unknown[]): Revision { + return { + object: 'revision', + id: 'rev-space-target', + type: 'edits', + pages, + files: [], + reusableContents: [], + tags: [], + parents: [], + createdAt: '', + urls: { app: '' }, + } as unknown as Revision; + } + + function buildCrossSpaceContext( + targetSpace: Space, + targetRevision: Revision, + structure: unknown + ) { + const currentSiteSpace = buildSiteSpace( + buildSpace('space-current', 'Current Space'), + 'Current Variant' + ); + const dataFetcher = { + getSpace: async ({ spaceId }: { spaceId: string }) => + spaceId === targetSpace.id + ? { data: targetSpace } + : { error: { code: 404, message: 'Not found' } }, + getRevision: async ({ spaceId }: { spaceId: string }) => + spaceId === targetSpace.id + ? { data: targetRevision } + : { error: { code: 404, message: 'Not found' } }, + getChangeRequest: async () => ({ error: { code: 404, message: 'Not found' } }), + withToken: function () { + return this; + }, + } as unknown as GitBookDataFetcher; + + return buildContext({ + siteSpace: currentSiteSpace, + structure, + dataFetcher, + }); + } + + it('prepends every localized section group and section to cross-space page ancestors', async () => { + const targetSpace = buildSpace('space-target', 'Target Space'); + const targetSiteSpace = buildSiteSpace(targetSpace, 'Target Variant'); + targetSiteSpace.urls = { published: 'https://docs.example.com/target/' }; + const pageGroup = { + object: 'page', + id: 'page-group', + type: 'group', + kind: 'group', + title: 'Getting started', + path: 'getting-started', + slug: 'getting-started', + pages: [buildDocumentPage('page-target', 'Target page', 'getting-started/target')], + }; + const section = { + object: 'site-section', + id: 'section-target', + title: 'Reference', + localizedTitle: { fr: 'Référence' }, + draft: false, + path: 'reference', + siteSpaces: [targetSiteSpace], + urls: {}, + }; + const sectionGroup = { + object: 'site-section-group', + id: 'section-group-target', + title: 'Product documentation', + localizedTitle: { fr: 'Documentation produit' }, + draft: false, + sections: [], + children: [ + { + object: 'site-section-group', + id: 'section-group-nested', + title: 'API guides', + draft: false, + sections: [], + children: [ + { + object: 'site-section-group', + id: 'section-group-child', + title: 'Authentication', + localizedTitle: { fr: 'Authentification' }, + draft: false, + sections: [section], + children: [section], + }, + ], + }, + ], + }; + const context = buildCrossSpaceContext(targetSpace, buildRevision([pageGroup]), { + type: 'sections', + structure: [sectionGroup], + }); + + const result = await resolveContentRef( + { kind: 'page', space: targetSpace.id, page: 'page-target' }, + { ...context, locale: 'fr' } as GitBookAnyContext + ); + + expect(result?.text).toBe('Target page'); + expect(result?.ancestors).toEqual([ + { label: 'Documentation produit' }, + { label: 'API guides' }, + { label: 'Authentification' }, + { label: 'Référence', href: targetSiteSpace.urls.published }, + { label: 'Getting started', icon: null, href: expect.any(String) }, + ]); + expect(result?.ancestors?.[0]?.href).toBeUndefined(); + expect(result?.ancestors?.[1]?.href).toBeUndefined(); + expect(result?.ancestors?.[2]?.href).toBeUndefined(); + expect(result?.ancestors?.[4]?.href).toBeTruthy(); + }); + + it('uses the first document as the target for a page-group reference', async () => { + const targetSpace = buildSpace('space-target', 'Target Space'); + const targetSiteSpace = buildSiteSpace(targetSpace, 'Target Variant'); + targetSiteSpace.urls = { published: 'https://docs.example.com/target/' }; + const pageGroup = { + object: 'page', + id: 'page-group', + type: 'group', + kind: 'group', + title: 'Learn about Cortex Agentix', + path: 'cortex-agentix', + slug: 'cortex-agentix', + pages: [buildDocumentPage('page-target', 'Cortex Agentix docs', 'cortex-agentix/docs')], + }; + const context = buildCrossSpaceContext(targetSpace, buildRevision([pageGroup]), { + type: 'siteSpaces', + structure: [targetSiteSpace], + }); + + const result = await resolveContentRef( + { kind: 'page', space: targetSpace.id, page: 'page-group' }, + context + ); + + expect(result?.page?.id).toBe('page-target'); + expect(result?.text).toBe('Learn about Cortex Agentix'); + expect(result?.ancestors?.map((ancestor) => ancestor.label)).toEqual([ + 'Target Variant', + 'Learn about Cortex Agentix', + ]); + expect( + result?.ancestors?.filter(({ label }) => label === 'Learn about Cortex Agentix') + ).toHaveLength(1); + }); + + it('uses the section instead of the variant for an ungrouped cross-space page', async () => { + const targetSpace = buildSpace('space-target', 'Target Space'); + const targetSiteSpace = buildSiteSpace(targetSpace, 'Target Variant'); + targetSiteSpace.urls = { published: 'https://docs.example.com/target/' }; + const section = { + object: 'site-section', + id: 'section-target', + title: 'Reference', + draft: false, + path: 'reference', + siteSpaces: [targetSiteSpace], + urls: {}, + }; + const context = buildCrossSpaceContext( + targetSpace, + buildRevision([buildDocumentPage('page-target', 'Target page', 'target')]), + { type: 'sections', structure: [section] } + ); + + const result = await resolveContentRef( + { kind: 'page', space: targetSpace.id, page: 'page-target' }, + context + ); + + expect(result?.ancestors).toEqual([ + { label: 'Reference', href: targetSiteSpace.urls.published }, + ]); + }); + + it('falls back to the target variant for a sectionless in-site page', async () => { + const targetSpace = buildSpace('space-target', 'Target Space'); + const targetSiteSpace = buildSiteSpace(targetSpace, 'Target Variant'); + targetSiteSpace.urls = { published: 'https://docs.example.com/target/' }; + const context = buildCrossSpaceContext( + targetSpace, + buildRevision([buildDocumentPage('page-target', 'Target page', 'target')]), + { type: 'siteSpaces', structure: [targetSiteSpace] } + ); + + const result = await resolveContentRef( + { kind: 'page', space: targetSpace.id, page: 'page-target' }, + context + ); + + expect(result?.ancestors).toEqual([ + { label: 'Target Variant', href: targetSiteSpace.urls.published }, + ]); + }); + + it('falls back to the raw space title for an external page', async () => { + const targetSpace = buildSpace('space-external', 'External Space'); + const context = buildCrossSpaceContext( + targetSpace, + buildRevision([buildDocumentPage('page-target', 'External page', 'target')]), + { type: 'siteSpaces', structure: [] } + ); + + const result = await resolveContentRef( + { kind: 'page', space: targetSpace.id, page: 'page-target' }, + context + ); + + expect(result?.text).toBe('External page'); + expect(result?.ancestors).toEqual([ + { label: 'External Space', href: targetSpace.urls.published }, + ]); + }); }); diff --git a/packages/gitbook/src/lib/references.tsx b/packages/gitbook/src/lib/references.tsx index bce57dae9..79eae72be 100644 --- a/packages/gitbook/src/lib/references.tsx +++ b/packages/gitbook/src/lib/references.tsx @@ -512,6 +512,42 @@ function getSpaceRefSectionLabel( return null; } +/** + * Ancestors to attach to a resolved content ref. Page/anchor links identify their + * containing section instead of repeating the target variant. + */ +function resolvePageAncestors( + context: GitBookAnyContext, + contentRef: ContentRef, + foundSiteSpace: ReturnType, + ctx: { spaceContext: GitBookSpaceContext; baseURL: URL } +): { label: string; href?: string }[] { + const isPageOrAnchorRef = contentRef.kind === 'page' || contentRef.kind === 'anchor'; + + if (isPageOrAnchorRef && foundSiteSpace?.siteSection) { + return [ + ...(foundSiteSpace.siteSectionGroups ?? []).map((group) => ({ + label: getLocalizedTitle(group, context.locale), + })), + { + label: getLocalizedTitle(foundSiteSpace.siteSection, context.locale), + href: ctx.baseURL.toString(), + }, + ]; + } + + if (foundSiteSpace?.siteSpace) { + return [ + { + label: getLocalizedTitle(foundSiteSpace.siteSpace, context.locale), + href: ctx.baseURL.toString(), + }, + ]; + } + + return [{ label: ctx.spaceContext.space.title, href: ctx.baseURL.toString() }]; +} + async function resolveContentRefInSpace( spaceId: string, context: GitBookAnyContext, @@ -546,19 +582,21 @@ async function resolveContentRefInSpace( return null; } - // Prefer the variant title when available, then the section title, then fallback to the space title. + const foundSiteSpace = + 'site' in context + ? findSiteSpaceBy(context.structure, (siteSpace) => siteSpace.space.id === spaceId) + : null; + + const ancestors = resolvePageAncestors(context, contentRef, foundSiteSpace, ctx); + + // Prefer the variant title when available, then the section title, then fallback to the space title for non-page refs. const ancestorLabel = (() => { if ('site' in context) { - const currentLanguage = context.locale; - const foundSiteSpace = findSiteSpaceBy( - context.structure, - (siteSpace) => siteSpace.space.id === spaceId - ); if (foundSiteSpace?.siteSpace) { - return getLocalizedTitle(foundSiteSpace.siteSpace, currentLanguage); + return getLocalizedTitle(foundSiteSpace.siteSpace, context.locale); } if (foundSiteSpace?.siteSection) { - return getLocalizedTitle(foundSiteSpace.siteSection, currentLanguage); + return getLocalizedTitle(foundSiteSpace.siteSection, context.locale); } return ctx.spaceContext.space.title; } @@ -569,10 +607,9 @@ async function resolveContentRefInSpace( return { ...resolved, ancestors: [ - { - label: ancestorLabel, - href: ctx.baseURL.toString(), - }, + ...(contentRef.kind === 'page' || contentRef.kind === 'anchor' + ? ancestors + : [{ label: ancestorLabel, href: ctx.baseURL.toString() }]), ...(resolved.ancestors ?? []), ].filter(filterOutNullable), }; diff --git a/packages/gitbook/src/lib/sites.test.ts b/packages/gitbook/src/lib/sites.test.ts index d6d412f59..7ca50171c 100644 --- a/packages/gitbook/src/lib/sites.test.ts +++ b/packages/gitbook/src/lib/sites.test.ts @@ -13,6 +13,7 @@ import { TranslationLanguage } from '@gitbook/api'; import { createLinker } from './links'; import { filterSiteSpacesByLocale, + findSiteSpaceBy, getFallbackSiteSpacePath, getLinkerForSiteSpace, getSiteStructureSections, @@ -79,6 +80,38 @@ describe('site structure traversal', () => { ]); expect(listAllSiteSpaces(structure)).toEqual([rootSpace, nestedSpace]); }); + + it('returns every section group from the root to the immediate parent', () => { + const targetSpace = { id: 'target-space' } as SiteSpace; + const targetSection = { + object: 'site-section', + id: 'target-section', + siteSpaces: [targetSpace], + } as SiteSection; + const childGroup = { + object: 'site-section-group', + id: 'child-group', + children: [targetSection], + } as SiteSectionGroup; + const firstChildGroup = { + object: 'site-section-group', + id: 'first-child-group', + children: [childGroup], + } as SiteSectionGroup; + const rootGroup = { + object: 'site-section-group', + id: 'root-group', + children: [firstChildGroup], + } as SiteSectionGroup; + + const found = findSiteSpaceBy( + { type: 'sections', structure: [rootGroup] }, + (siteSpace) => siteSpace.id === targetSpace.id + ); + + expect(found?.siteSectionGroup).toBe(childGroup); + expect(found?.siteSectionGroups).toEqual([rootGroup, firstChildGroup, childGroup]); + }); }); describe('filterSiteSpacesByLocale', () => { diff --git a/packages/gitbook/src/lib/sites.ts b/packages/gitbook/src/lib/sites.ts index b4a2eb7c6..9e735a3d5 100644 --- a/packages/gitbook/src/lib/sites.ts +++ b/packages/gitbook/src/lib/sites.ts @@ -264,6 +264,7 @@ export function findSiteSpaceBy( siteSpace: SiteSpace; siteSection: SiteSection | null; siteSectionGroup: SiteSectionGroup | null; + siteSectionGroups: SiteSectionGroup[]; } | null { if (siteStructure.type === 'siteSpaces') { const siteSpace = siteStructure.structure.find(predicate) ?? null; @@ -272,6 +273,7 @@ export function findSiteSpaceBy( siteSpace, siteSection: null, siteSectionGroup: null, + siteSectionGroups: [], }; } @@ -290,6 +292,7 @@ export function findSiteSpaceBy( siteSpace, siteSection: sectionOrGroup, siteSectionGroup: null, + siteSectionGroups: [], }; } break; @@ -361,11 +364,14 @@ export function getFallbackSiteSpacePath(context: GitBookSiteContext, siteSpace: function findSiteSpaceByIdInGroupChildren( children: SiteStructureNode[], predicate: (siteSpace: SiteSpace) => boolean, - parentGroup: SiteSectionGroup + parentGroup: SiteSectionGroup, + rootGroup: SiteSectionGroup = parentGroup, + sectionGroups: SiteSectionGroup[] = [parentGroup] ): { siteSpace: SiteSpace; siteSection: SiteSection; siteSectionGroup: SiteSectionGroup; + siteSectionGroups: SiteSectionGroup[]; } | null { for (const child of children) { switch (child.object) { @@ -376,12 +382,19 @@ function findSiteSpaceByIdInGroupChildren( siteSpace, siteSection: child, siteSectionGroup: parentGroup, + siteSectionGroups: sectionGroups, }; } break; } case 'site-section-group': { - const found = findSiteSpaceByIdInGroupChildren(child.children, predicate, child); + const found = findSiteSpaceByIdInGroupChildren( + child.children, + predicate, + child, + rootGroup, + [...sectionGroups, child] + ); if (found) { return found; } From 1b7ac0e41bf8a108cf973125716de551c2a54a78 Mon Sep 17 00:00:00 2001 From: arnau Date: Wed, 2 Sep 2026 12:05:37 +0200 Subject: [PATCH 03/10] Open inline Ask AI questions in the configured assistant (#4574) --- .changeset/clean-cats-ask.md | 5 +++++ .../src/components/DocumentView/InlineActionButton.tsx | 8 ++------ 2 files changed, 7 insertions(+), 6 deletions(-) create mode 100644 .changeset/clean-cats-ask.md diff --git a/.changeset/clean-cats-ask.md b/.changeset/clean-cats-ask.md new file mode 100644 index 000000000..560a22723 --- /dev/null +++ b/.changeset/clean-cats-ask.md @@ -0,0 +1,5 @@ +--- +'gitbook': patch +--- + +Fix inline Ask AI buttons opening a configured custom assistant. diff --git a/packages/gitbook/src/components/DocumentView/InlineActionButton.tsx b/packages/gitbook/src/components/DocumentView/InlineActionButton.tsx index 1279f8527..d794bef67 100644 --- a/packages/gitbook/src/components/DocumentView/InlineActionButton.tsx +++ b/packages/gitbook/src/components/DocumentView/InlineActionButton.tsx @@ -1,5 +1,5 @@ 'use client'; -import { useAI, useAIChatController, useAIChatState } from '../AI'; +import { useAI, useAIChatState } from '../AI'; import { Button, type ButtonProps, Input } from '../primitives'; import { useSetSearchState } from '../Search'; import { tString, useLanguage } from '@/intl/client'; @@ -18,17 +18,13 @@ export function InlineActionButton( const { action, query, buttonProps } = props; const { assistants } = useAI(); - const chatController = useAIChatController(); const chatState = useAIChatState(); const setSearchState = useSetSearchState(); const language = useLanguage(); const handleSubmit = (value: string) => { if (action === 'ask') { - chatController.open(); - if (value ?? query) { - chatController.postMessage({ message: value ?? query }); - } + assistants[0]?.open(value || query); } else if (action === 'search') { setSearchState((prev) => ({ ...prev, From 8ad74658015b1d717a44f1a823e9484e0217182c Mon Sep 17 00:00:00 2001 From: Tomek Date: Wed, 2 Sep 2026 16:32:53 +0200 Subject: [PATCH 04/10] Simplify carousel overflow with edge masks & fix carousel scroll (#4577) Co-authored-by: Claude Haiku 4.5 --- .changeset/bound-desktop-card-carousel.md | 5 + .../DocumentView/Table/ViewCards.test.tsx | 63 ++++++++ .../DocumentView/Table/ViewCards.tsx | 79 ++-------- .../primitives/ScrollContainer.test.ts | 99 ++++++++++++ .../components/primitives/ScrollContainer.tsx | 141 ++++++++++++++++++ 5 files changed, 318 insertions(+), 69 deletions(-) create mode 100644 .changeset/bound-desktop-card-carousel.md create mode 100644 packages/gitbook/src/components/DocumentView/Table/ViewCards.test.tsx create mode 100644 packages/gitbook/src/components/primitives/ScrollContainer.test.ts diff --git a/.changeset/bound-desktop-card-carousel.md b/.changeset/bound-desktop-card-carousel.md new file mode 100644 index 000000000..99a21a2dc --- /dev/null +++ b/.changeset/bound-desktop-card-carousel.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Simplify carousel overflow with symmetric edge masks and visible-item paging. Replaces complex negative-margin bleed logic with transparent edge fades and page-by-visible-item scrolling. diff --git a/packages/gitbook/src/components/DocumentView/Table/ViewCards.test.tsx b/packages/gitbook/src/components/DocumentView/Table/ViewCards.test.tsx new file mode 100644 index 000000000..70819f674 --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/Table/ViewCards.test.tsx @@ -0,0 +1,63 @@ +import { describe, expect, it, mock } from 'bun:test'; +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import { IconsProvider } from '@gitbook/icons'; + +import { TranslateContext } from '@/intl/client'; +import { en } from '@/intl/translations/en'; + +mock.module('./RecordCard', () => ({ + RecordCard: () =>
, +})); + +mock.module('./TableSearch', () => ({ + TableSearchRecord: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +const { ViewCards } = await import('./ViewCards'); + +function makeProps(wrap: boolean) { + return { + block: { data: { fullWidth: false } }, + view: { type: 'cards', wrap, cardSize: 'medium' }, + records: [ + ['record-1', {}], + ['record-2', {}], + ['record-3', {}], + ] as any, + context: { mode: 'default' }, + style: undefined, + } as any; +} + +function renderWithContext(children: React.ReactNode) { + return renderToStaticMarkup( + + {children} + + ); +} + +describe('ViewCards', () => { + it('renders the carousel with a symmetric peek and conditional edge masks', () => { + const markup = renderWithContext(); + + expect(markup).toContain('-mx-12'); + expect(markup).toContain('px-12'); + expect(markup).toContain('scroll-px-12'); + expect(markup).toContain('left-0'); + expect(markup).toContain('ml-8'); + expect(markup).toContain('right-0'); + expect(markup).toContain('mr-8'); + expect(markup).not.toContain('before:bg-linear-to-r'); + expect(markup).not.toContain('after:bg-linear-to-l'); + expect(markup).toContain('snap-mandatory'); + }); + + it('renders the wrapping grid by default', () => { + const markup = renderWithContext(); + + expect(markup).toContain('inline-grid'); + }); +}); diff --git a/packages/gitbook/src/components/DocumentView/Table/ViewCards.tsx b/packages/gitbook/src/components/DocumentView/Table/ViewCards.tsx index 6a3ff185b..8d47d3ef2 100644 --- a/packages/gitbook/src/components/DocumentView/Table/ViewCards.tsx +++ b/packages/gitbook/src/components/DocumentView/Table/ViewCards.tsx @@ -53,11 +53,9 @@ function CardsGrid(props: TableViewProps) { * The carousel layout: cards lay out in a single horizontally-scrolling row that * snaps to the leftmost card. Reuses ScrollContainer for the scroll buttons. * - * Rather than fading the edges, the row breaks out of the content column so it can - * scroll to the page edges. Negative margins on the outer wrapper pull it out; matching - * padding + scroll-padding on the scroller keep the first/last cards aligned with the - * body text at rest and snapping to that edge, while cards bleed to the edge mid-scroll. - * See `bleedVars` below for how far each side reaches. + * The row extends by a small, symmetric peek on each side. The matching padding keeps + * the first and last cards aligned with the content column while the edge masks fade only + * the cards in that peek area. */ function CardsCarousel(props: TableViewProps) { const { view, records } = props; @@ -69,65 +67,10 @@ function CardsCarousel(props: TableViewProps) { ? 'w-[90%] @sm:w-[calc(45%-0.5rem)] @5xl:w-[calc(50%-0.5rem)]' : 'w-[90%] @sm:w-[calc(45%-0.5rem)] @xl:w-[calc(30%-0.66rem)] @5xl:w-[calc(33.33%-0.66rem)]'; - // Break the row out of the content column so it bleeds to the page edges instead of - // fading. `--cards-bleed-l/r` are the distances to pull each side out by; they drive the - // negative margins (on the wrapper) and the matching padding + scroll-padding (on the - // scroller), so the first/last cards stay aligned with the body text at rest while cards - // bleed to the edge mid-scroll. Kept as literals here since it's a single block. - // - // The bleed only makes sense on the default layout, where the 48rem column leaves wide - // empty margins to reclaim. The wide layout (max-w-6xl) already fills the usable width, - // so from `lg` we suppress the bleed entirely — otherwise the page gutter would push the - // row past where every other block ends, jutting into the window frame. - // - // On the default layout: - // - Left is capped at the page gutter (1/1.5/2rem) so it never slides under the TOC. - // - Right reaches the viewport edge from `lg`. The 48rem column is centred in the space - // beside the TOC, so the gap to the viewport edge is `50vw` minus half the column - // (24rem), minus half the TOC (10.5rem of the 21rem `w-72`+`mr-12`) when one is shown. - // `html` clips horizontal overflow, so a small overshoot is harmless. - // - Right collapses to 0 once an outline occupies that column (shown from `xl`), so - // cards never slide under it. - const bleedVars = tcls( - '[--cards-bleed-l:1rem]', - 'sm:[--cards-bleed-l:1.5rem]', - 'md:[--cards-bleed-l:2rem]', - 'layout-default:md:max-lg:[--cards-bleed-l:max(calc(50vw-24.5rem),2rem)]', - 'lg:[--cards-bleed-l:max(calc(50vw-34rem),3rem)]', - 'xl:[--cards-bleed-l:3rem]', - - '[--cards-bleed-r:1rem]', - 'sm:[--cards-bleed-r:1.5rem]', - 'md:[--cards-bleed-r:2rem]', - 'layout-default:md:max-lg:[--cards-bleed-r:max(calc(50vw-24.5rem),2rem)]', - 'layout-default:lg:[--cards-bleed-r:max(calc(50vw-35rem),3rem)]', - 'layout-default:xl:[--cards-bleed-r:3rem]', - - 'hover:layout-default:no-sidebar:lg:max-xl:[--cards-bleed-l:max(calc(50vw-24.5rem),2rem)]', - 'hover:layout-default:no-sidebar:lg:max-xl:[--cards-bleed-r:max(calc(50vw-24.5rem),2rem)]', - - // Default centered - 'hover:layout-default:no-sidebar:xl:[--cards-bleed-l:max(calc(50vw-22.5rem),2rem)]', - 'hover:layout-default:xl:[--cards-bleed-r:max(calc(50vw-26.5rem),19rem)]', - - // Full width, no outline - 'hover:layout-wide:page-no-outline:2xl:[--cards-bleed-r:max(calc(50vw-43.5rem),0rem)]', - - // Full width centered - 'layout-wide:no-sidebar:page-no-outline:2xl:[--cards-bleed-l:max(calc(50vw-36.5rem),0rem)]', - 'layout-wide:no-sidebar:page-no-outline:2xl:[--cards-bleed-r:max(calc(50vw-36.5rem),0rem)]' - ); - return ( ) { '-mt-px', 'pb-6', '-mb-6', - 'pl-[var(--cards-bleed-l)]', - 'pr-[var(--cards-bleed-r)]', - 'scroll-pl-[var(--cards-bleed-l)]', - 'scroll-pr-[var(--cards-bleed-r)]', + 'px-12', + 'scroll-px-12', 'snap-x', - 'snap-mandatory', - 'xl:transition-[padding]' + 'snap-mandatory' )} + scrollByVisibleItems leading={{ fade: true, - button: { size: 'small', className: 'ml-[calc(var(--cards-bleed-l)-1rem)]' }, + button: { size: 'small', className: 'ml-8' }, }} trailing={{ fade: true, - button: { size: 'small', className: 'mr-[calc(var(--cards-bleed-r)-1rem)]' }, + button: { size: 'small', className: 'mr-8' }, }} > {records.map((record) => { diff --git a/packages/gitbook/src/components/primitives/ScrollContainer.test.ts b/packages/gitbook/src/components/primitives/ScrollContainer.test.ts new file mode 100644 index 000000000..15afbf10c --- /dev/null +++ b/packages/gitbook/src/components/primitives/ScrollContainer.test.ts @@ -0,0 +1,99 @@ +import { afterAll, beforeAll, describe, expect, it } from 'bun:test'; + +import { scrollByItemsInContainer } from './ScrollContainer'; + +type MockRect = { + left: number; + right: number; + top: number; + bottom: number; + width: number; + height: number; +}; + +class MockElement { + constructor(private readonly rect: MockRect) {} + + getBoundingClientRect() { + return this.rect; + } +} + +const originalHTMLElement = globalThis.HTMLElement; + +beforeAll(() => { + Object.defineProperty(globalThis, 'HTMLElement', { + configurable: true, + value: MockElement, + }); +}); + +afterAll(() => { + Object.defineProperty(globalThis, 'HTMLElement', { + configurable: true, + value: originalHTMLElement, + }); +}); + +function rect(left: number, right: number): MockRect { + return { left, right, top: 0, bottom: 100, width: right - left, height: 100 }; +} + +function makeContainer( + childRects: MockRect[], + options: { scrollLeft?: number; scrollWidth?: number } = {} +) { + const scrollCalls: Record[] = []; + const container = Object.assign(new MockElement(rect(0, 300)), { + children: childRects.map((childRect) => new MockElement(childRect)), + clientHeight: 100, + clientWidth: 300, + scrollHeight: 100, + scrollLeft: options.scrollLeft ?? 0, + scrollTop: 0, + scrollWidth: options.scrollWidth ?? 1000, + scrollTo: (options: Record) => scrollCalls.push(options), + }); + + return { container: container as unknown as HTMLElement, scrollCalls }; +} + +describe('scrollByItemsInContainer', () => { + it('advances by fully visible items and excludes a partial preview', () => { + const { container, scrollCalls } = makeContainer([ + rect(0, 100), + rect(110, 210), + rect(220, 320), + rect(330, 430), + ]); + + scrollByItemsInContainer(container, 'horizontal', 'forward'); + + expect(scrollCalls).toEqual([{ top: undefined, left: 220, behavior: 'smooth' }]); + }); + + it('moves backward by the visible page size', () => { + const { container, scrollCalls } = makeContainer( + [rect(-220, -120), rect(-110, -10), rect(0, 100), rect(110, 210), rect(220, 320)], + { scrollLeft: 220 } + ); + + scrollByItemsInContainer(container, 'horizontal', 'backward'); + + expect(scrollCalls).toEqual([{ top: undefined, left: 0, behavior: 'smooth' }]); + }); + + it('clamps at the first and last scroll positions', () => { + const firstPage = makeContainer([rect(0, 100), rect(110, 210), rect(220, 320)]); + scrollByItemsInContainer(firstPage.container, 'horizontal', 'backward'); + + const lastPage = makeContainer( + [rect(-240, -140), rect(-130, -30), rect(-20, 80), rect(90, 190), rect(200, 300)], + { scrollLeft: 240, scrollWidth: 540 } + ); + scrollByItemsInContainer(lastPage.container, 'horizontal', 'forward'); + + expect(firstPage.scrollCalls).toEqual([{ top: undefined, left: 0, behavior: 'smooth' }]); + expect(lastPage.scrollCalls).toEqual([{ top: undefined, left: 240, behavior: 'smooth' }]); + }); +}); diff --git a/packages/gitbook/src/components/primitives/ScrollContainer.tsx b/packages/gitbook/src/components/primitives/ScrollContainer.tsx index 0d9297023..4b9475ca9 100644 --- a/packages/gitbook/src/components/primitives/ScrollContainer.tsx +++ b/packages/gitbook/src/components/primitives/ScrollContainer.tsx @@ -41,6 +41,9 @@ export type ScrollContainerProps = { /** The ID or ref of the active item to scroll to. */ active?: string | React.RefObject; + + /** Scroll by one page of fully visible direct children instead of one viewport. */ + scrollByVisibleItems?: boolean; } & React.HTMLAttributes; export function ScrollContainer(props: ScrollContainerProps) { @@ -50,6 +53,7 @@ export function ScrollContainer(props: ScrollContainerProps) { contentClassName, orientation, active, + scrollByVisibleItems = false, leading = { fade: true, button: true }, trailing = { fade: true, button: true }, ...rest @@ -85,6 +89,11 @@ export function ScrollContainer(props: ScrollContainerProps) { return; } + if (scrollByVisibleItems) { + scrollByItemsInContainer(container, orientation, 'forward'); + return; + } + container.scrollTo({ top: orientation === 'vertical' ? scrollPosition + container.clientHeight : undefined, left: orientation === 'horizontal' ? scrollPosition + container.clientWidth : undefined, @@ -98,6 +107,11 @@ export function ScrollContainer(props: ScrollContainerProps) { return; } + if (scrollByVisibleItems) { + scrollByItemsInContainer(container, orientation, 'backward'); + return; + } + container.scrollTo({ top: orientation === 'vertical' ? scrollPosition - container.clientHeight : undefined, left: orientation === 'horizontal' ? scrollPosition - container.clientWidth : undefined, @@ -191,6 +205,133 @@ export function ScrollContainer(props: ScrollContainerProps) { ); } +const FULLY_VISIBLE_EDGE_TOLERANCE_PX = 1; + +/** + * Scroll a direct-child track by the number of items currently visible in the snapport. + * Scroll padding is excluded from the measurement because it is the carousel's peek area. + */ +export function scrollByItemsInContainer( + container: HTMLElement, + orientation: 'horizontal' | 'vertical', + direction: 'forward' | 'backward' +) { + const children = Array.from(container.children).filter( + (child): child is HTMLElement => child instanceof HTMLElement + ); + const bounds = getScrollBounds(container, orientation); + const items = children + .map((element, index) => ({ element, index, rect: element.getBoundingClientRect() })) + .filter(({ rect }) => { + const size = orientation === 'horizontal' ? rect.width : rect.height; + return size > 0; + }) + .map((item, index) => ({ ...item, index })); + const visibleItems = items.filter(({ rect }) => { + const start = orientation === 'horizontal' ? rect.left : rect.top; + const end = orientation === 'horizontal' ? rect.right : rect.bottom; + return ( + start >= bounds.start - FULLY_VISIBLE_EDGE_TOLERANCE_PX && + end <= bounds.end + FULLY_VISIBLE_EDGE_TOLERANCE_PX + ); + }); + + // A track narrower than its viewport, or one whose children have not laid out yet, should + // retain the regular viewport behavior rather than getting stuck at its current position. + if (visibleItems.length === 0) { + scrollByViewport(container, orientation, direction); + return; + } + + const pageSize = visibleItems.length; + const firstVisibleItem = visibleItems[0]; + const lastVisibleItem = visibleItems[visibleItems.length - 1]; + if (!firstVisibleItem || !lastVisibleItem) { + scrollByViewport(container, orientation, direction); + return; + } + const targetIndex = + direction === 'forward' ? lastVisibleItem.index + 1 : firstVisibleItem.index - pageSize; + const maxScroll = getMaxScroll(container, orientation); + + if (targetIndex < 0) { + scrollToPosition(container, orientation, 0); + return; + } + + const target = items.find((item) => item.index === targetIndex); + if (!target) { + scrollToPosition(container, orientation, maxScroll); + return; + } + + const targetStart = orientation === 'horizontal' ? target.rect.left : target.rect.top; + const targetPosition = + (orientation === 'horizontal' ? container.scrollLeft : container.scrollTop) + + targetStart - + bounds.start; + + scrollToPosition(container, orientation, Math.min(Math.max(targetPosition, 0), maxScroll)); +} + +function getScrollBounds(container: HTMLElement, orientation: 'horizontal' | 'vertical') { + const rect = container.getBoundingClientRect(); + const computedStyle = typeof window !== 'undefined' ? window.getComputedStyle(container) : null; + const leadingPadding = Number.parseFloat( + computedStyle?.[orientation === 'horizontal' ? 'scrollPaddingLeft' : 'scrollPaddingTop'] ?? + '' + ); + const trailingPadding = Number.parseFloat( + computedStyle?.[ + orientation === 'horizontal' ? 'scrollPaddingRight' : 'scrollPaddingBottom' + ] ?? '' + ); + const start = orientation === 'horizontal' ? rect.left : rect.top; + const end = orientation === 'horizontal' ? rect.right : rect.bottom; + + return { + start: start + (Number.isFinite(leadingPadding) ? leadingPadding : 0), + end: end - (Number.isFinite(trailingPadding) ? trailingPadding : 0), + }; +} + +function getMaxScroll(container: HTMLElement, orientation: 'horizontal' | 'vertical') { + return Math.max( + orientation === 'horizontal' + ? container.scrollWidth - container.clientWidth + : container.scrollHeight - container.clientHeight, + 0 + ); +} + +function scrollToPosition( + container: HTMLElement, + orientation: 'horizontal' | 'vertical', + position: number +) { + container.scrollTo({ + top: orientation === 'vertical' ? position : undefined, + left: orientation === 'horizontal' ? position : undefined, + behavior: 'smooth', + }); +} + +function scrollByViewport( + container: HTMLElement, + orientation: 'horizontal' | 'vertical', + direction: 'forward' | 'backward' +) { + const position = orientation === 'horizontal' ? container.scrollLeft : container.scrollTop; + const distance = orientation === 'horizontal' ? container.clientWidth : container.clientHeight; + const maxScroll = getMaxScroll(container, orientation); + const target = Math.min( + Math.max(position + (direction === 'forward' ? distance : -distance), 0), + maxScroll + ); + + scrollToPosition(container, orientation, target); +} + /** * Scroll to an element in a container. */ From 77058e3f17b2a86d71ef780e43ee0a8045d18d81 Mon Sep 17 00:00:00 2001 From: Viktor Renkema <49148610+viktorrenkema@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:26:43 +0200 Subject: [PATCH 05/10] Support merged cells (#4500) Co-authored-by: OpenAI Codex --- .changeset/quiet-dogs-merge.md | 5 + .../AskAIParagraphButton.tsx | 4 +- .../DocumentView/Table/NativeViewGrid.tsx | 291 ++++++++++++++++++ .../DocumentView/Table/RecordRow.tsx | 32 +- .../DocumentView/Table/StickyViewGrid.tsx | 7 +- .../components/DocumentView/Table/Table.tsx | 38 ++- .../Table/TableHoverTable.test.ts | 44 +++ .../DocumentView/Table/TableHoverTable.tsx | 93 ++++++ .../DocumentView/Table/TableSearch.tsx | 64 ++-- .../DocumentView/Table/ViewGrid.tsx | 8 +- .../DocumentView/Table/cellMerges.test.ts | 217 +++++++++++++ .../DocumentView/Table/cellMerges.ts | 232 ++++++++++++++ .../DocumentView/Table/searchMatch.test.ts | 98 +++++- .../DocumentView/Table/searchMatch.ts | 66 ++++ 14 files changed, 1139 insertions(+), 60 deletions(-) create mode 100644 .changeset/quiet-dogs-merge.md create mode 100644 packages/gitbook/src/components/DocumentView/Table/NativeViewGrid.tsx create mode 100644 packages/gitbook/src/components/DocumentView/Table/TableHoverTable.test.ts create mode 100644 packages/gitbook/src/components/DocumentView/Table/TableHoverTable.tsx create mode 100644 packages/gitbook/src/components/DocumentView/Table/cellMerges.test.ts create mode 100644 packages/gitbook/src/components/DocumentView/Table/cellMerges.ts diff --git a/.changeset/quiet-dogs-merge.md b/.changeset/quiet-dogs-merge.md new file mode 100644 index 000000000..762a48e92 --- /dev/null +++ b/.changeset/quiet-dogs-merge.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Render horizontal and vertical merged table cells on published pages. diff --git a/packages/gitbook/src/components/AIChat/AskAIParagraphButton/AskAIParagraphButton.tsx b/packages/gitbook/src/components/AIChat/AskAIParagraphButton/AskAIParagraphButton.tsx index b71b3d2bc..7f4034c2c 100644 --- a/packages/gitbook/src/components/AIChat/AskAIParagraphButton/AskAIParagraphButton.tsx +++ b/packages/gitbook/src/components/AIChat/AskAIParagraphButton/AskAIParagraphButton.tsx @@ -53,8 +53,10 @@ export function AskAIParagraphButton(props: { content: string; className?: Class 'hover:visible hover:opacity-100 group-hover/ask-ai:visible group-hover/ask-ai:opacity-100', // Never shown on touch / hover-less contexts. 'not-pointer-fine:hidden', - // Hidden where an overflow-clipped ancestor would cut it off (tables, record cards). + // Hidden where an overflow-clipped ancestor would cut it off (ARIA and native + // tables, record cards). 'in-[[role=table]]:hidden', + 'in-[table]:hidden', 'in-[[data-card]]:hidden', className )} diff --git a/packages/gitbook/src/components/DocumentView/Table/NativeViewGrid.tsx b/packages/gitbook/src/components/DocumentView/Table/NativeViewGrid.tsx new file mode 100644 index 000000000..487af55b2 --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/Table/NativeViewGrid.tsx @@ -0,0 +1,291 @@ +import { getTableCellMerge } from './cellMerges'; +import { getColumnWidth, getViewGridLayout, hasVisibleHeader } from './layout'; +import { RecordColumnValue } from './RecordColumnValue'; +import { StickyViewGrid } from './StickyViewGrid'; +import type { TableGridViewProps } from './Table'; +import { TableHoverTable } from './TableHoverTable'; +import { TableSearchTableBody } from './TableSearch'; +import { type VerticalAlignment, getColumnAlignment, getColumnVerticalAlignment } from './utils'; +import { tcls } from '@/lib/tailwind'; + +/** Semantic table renderer used when a cell spans multiple records. */ +export function NativeViewGrid(props: TableGridViewProps) { + const { block, view, context, style } = props; + const { tableWidth } = getViewGridLayout({ + block, + view, + mode: context.mode, + }); + const tableContainerClassName = tableWidth === 'w-full' ? 'min-w-full w-fit' : tableWidth; + const withHeader = hasVisibleHeader(block, view); + const withStickyHeader = withHeader && context.mode !== 'print' && view.stickyHeader === true; + const withStickyFirstColumn = context.mode !== 'print' && view.stickyFirstColumn === true; + const header = withHeader ? ( + + ) : undefined; + const body = ( + + ); + + if (withStickyHeader || withStickyFirstColumn) { + return ( + + {body} + + ); + } + + return ( +
+
+
+ {header} + {body} +
+
+
+ ); +} + +function NativeViewGridHeader( + props: TableGridViewProps & { + stickyHeader: boolean; + stickyFirstColumn: boolean; + className?: string; + } +) { + const { block, view, stickyHeader, stickyFirstColumn, className } = props; + const firstVisibleColumn = view.columns[0]; + + return ( + + ); +} + +function NativeViewGridBody( + props: TableGridViewProps & { + withHeader: boolean; + stickyFirstColumn: boolean; + } +) { + const { block, view, records, withHeader, stickyFirstColumn, cellMergeLayout } = props; + const firstVisibleColumn = view.columns[0]; + const lastVisibleColumn = view.columns.at(-1); + const recordsById = new Map(records.map((record) => [record[0], record] as const)); + const recordIndexes = new Map(records.map((record, index) => [record[0], index] as const)); + + return ( + tbody>tr>td[data-table-hovered]]:bg-tint-hover', + '[&>tbody>tr+tr>td]:border-t', + '[&>tbody+tbody>tr:first-child>td]:border-t' + )} + > + + {withHeader ? ( + + + {view.columns.map((column) => { + const definition = block.data.definition[column]; + return definition ? ( + + {definition.title} + + ) : null; + })} + + + ) : null} + {cellMergeLayout.recordGroups.map((recordGroup) => ( + + {recordGroup.map((recordId) => { + const record = recordsById.get(recordId); + const recordIndex = recordIndexes.get(recordId); + if (!record || recordIndex === undefined) { + return null; + } + + return ( + + {view.columns.map((column) => { + const cellMerge = getTableCellMerge( + cellMergeLayout, + recordId, + column + ); + if (cellMerge && !cellMerge.isAnchor) { + return null; + } + + const definition = block.data.definition[column]; + if (!definition) { + return null; + } + + const verticalAlignment = + getColumnVerticalAlignment(definition); + const renderedColumns = cellMerge?.merge.columns ?? [column]; + const isLastColumn = + renderedColumns.at(-1) === lastVisibleColumn; + const isStickyFirstColumn = + stickyFirstColumn && column === firstVisibleColumn; + const rowSpan = + cellMerge && cellMerge.merge.rowSpan > 1 + ? cellMerge.merge.rowSpan + : undefined; + const colSpan = + cellMerge && cellMerge.merge.colSpan > 1 + ? cellMerge.merge.colSpan + : undefined; + return ( + + + + ); + })} + + ); + })} + + ))} + + ); +} + +function NativeViewGridColumns(props: TableGridViewProps) { + const { block, view, context } = props; + const { columnWidths, autoSizedColumns, fixedColumns } = getViewGridLayout({ + block, + view, + mode: context.mode, + }); + + return ( + + {view.columns.map((column) => ( + + ))} + + ); +} + +function getNativeCellVerticalAlignment(verticalAlignment: VerticalAlignment) { + switch (verticalAlignment) { + case 'self-start': + return 'align-top'; + case 'self-end': + return 'align-bottom'; + case 'self-center': + return 'align-middle'; + } +} diff --git a/packages/gitbook/src/components/DocumentView/Table/RecordRow.tsx b/packages/gitbook/src/components/DocumentView/Table/RecordRow.tsx index 69e0e419e..c0cae2357 100644 --- a/packages/gitbook/src/components/DocumentView/Table/RecordRow.tsx +++ b/packages/gitbook/src/components/DocumentView/Table/RecordRow.tsx @@ -1,20 +1,19 @@ -import type { DocumentTableViewGrid } from '@gitbook/api'; - +import { getMergedCellWidth, getTableCellMerge } from './cellMerges'; import { getColumnWidth } from './layout'; import { RecordColumnValue } from './RecordColumnValue'; -import type { TableRecordKV, TableViewProps } from './Table'; +import type { TableGridViewProps, TableRecordKV } from './Table'; import { TableSearchRecord } from './TableSearch'; import { getColumnVerticalAlignment } from './utils'; import { tcls } from '@/lib/tailwind'; export function RecordRow( - props: TableViewProps & { + props: TableGridViewProps & { record: TableRecordKV; autoSizedColumns: string[]; fixedColumns: string[]; } ) { - const { view, record, autoSizedColumns, fixedColumns, block, context } = props; + const { view, record, autoSizedColumns, fixedColumns, block, context, cellMergeLayout } = props; const stickyFirstColumn = context.mode !== 'print' && view.stickyFirstColumn === true; const firstVisibleColumn = view.columns[0]; @@ -31,12 +30,22 @@ export function RecordRow( )} > {view.columns.map((column) => { - const columnWidth = getColumnWidth({ - column, - columnWidths: context.mode === 'print' ? undefined : view.columnWidths, - autoSizedColumns, - fixedColumns, - }); + const cellMerge = getTableCellMerge(cellMergeLayout, record[0], column); + if (cellMerge && !cellMerge.isAnchor) { + return null; + } + + const renderedColumns = cellMerge?.merge.columns ?? [column]; + const columnWidth = getMergedCellWidth( + renderedColumns.map((mergedColumn) => + getColumnWidth({ + column: mergedColumn, + columnWidths: context.mode === 'print' ? undefined : view.columnWidths, + autoSizedColumns, + fixedColumns, + }) + ) + ); const isStickyFirstColumnCell = stickyFirstColumn && column === firstVisibleColumn; // @ts-expect-error const verticalAlignment = getColumnVerticalAlignment(block.data.definition[column]); @@ -45,6 +54,7 @@ export function RecordRow(
@@ -48,6 +50,7 @@ function StickyHeaderOverlayScrollGrid({ className, header, tableClassName, + withTableRole = true, children, }: StickyViewGridProps) { const rootRef = useRef(null); @@ -141,7 +144,7 @@ function StickyHeaderOverlayScrollGrid({ ref={rootRef} className="group/table relative flex w-full min-w-0 max-w-full flex-col rounded-lg border-tint-subtle data-[scrollable=true]:border" data-scrollable="false" - role="table" + role={withTableRole ? 'table' : undefined} > {header ? (
extends BlockProps { isOffscreen: boolean; } +export interface TableGridViewProps extends TableViewProps { + cellMergeLayout: TableCellMergeLayout; +} + export function Table(props: BlockProps) { const { block, ancestorBlocks, document, context, style } = props; const isOffscreen = isBlockOffscreen({ block, ancestorBlocks, document }); @@ -45,9 +51,18 @@ export function Table(props: BlockProps) { const searchRecords = showSearch ? records.map(([id, record]) => ({ id, ...getTableRecordSearchData(block, record) })) : []; + const cellMergeLayout = createTableCellMergeLayout( + block, + records.map(([recordId]) => recordId) + ); return ( - +
{showSearch ? ( ) { checkboxColumns={getTableCheckboxColumns(block)} /> ) : null} - +
@@ -68,8 +88,13 @@ export function Table(props: BlockProps) { function TableView({ isOffscreen, records, + cellMergeLayout, ...props -}: BlockProps & { isOffscreen: boolean; records: TableRecordKV[] }) { +}: BlockProps & { + isOffscreen: boolean; + records: TableRecordKV[]; + cellMergeLayout: TableCellMergeLayout; +}) { const { block, context, style } = props; switch (block.data.view.type) { @@ -88,6 +113,7 @@ function TableView({ view: block.data.view, isOffscreen, records, + cellMergeLayout, }; const { tableWidth } = getViewGridLayout({ block, @@ -102,6 +128,10 @@ function TableView({ const withStickyFirstColumn = context.mode !== 'print' && block.data.view.stickyFirstColumn === true; + if (cellMergeLayout.hasVerticalMerges) { + return ; + } + if (withStickyHeader || withStickyFirstColumn) { return ( ; + +function getHighlightedCells(hoveredCell: keyof typeof CELL_RANGES) { + const hoveredRange = CELL_RANGES[hoveredCell]; + return Object.entries(CELL_RANGES) + .filter(([, cellRange]) => tableRowRangesIntersect(cellRange, hoveredRange)) + .map(([cell]) => cell); +} + +describe('tableRowRangesIntersect', () => { + it.each([ + ['A', ['A', 'B', 'C', 'D', 'E']], + ['B', ['A', 'B', 'C']], + ['D', ['A', 'D', 'E']], + ['E', ['A', 'D', 'E', 'F', 'G']], + ] as const)( + 'highlights cells intersecting %s without transitive expansion', + (cell, expected) => { + expect(getHighlightedCells(cell)).toEqual([...expected]); + } + ); + + it('treats a horizontal merge as a single-row range', () => { + const horizontalMerge = { rowStart: 1, rowEnd: 1 }; + + expect( + Object.entries(CELL_RANGES) + .filter(([, cellRange]) => tableRowRangesIntersect(cellRange, horizontalMerge)) + .map(([cell]) => cell) + ).toEqual(['A', 'D', 'E']); + }); +}); diff --git a/packages/gitbook/src/components/DocumentView/Table/TableHoverTable.tsx b/packages/gitbook/src/components/DocumentView/Table/TableHoverTable.tsx new file mode 100644 index 000000000..f4602cd77 --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/Table/TableHoverTable.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { type ComponentPropsWithoutRef, type MouseEvent as ReactMouseEvent, useRef } from 'react'; + +const TABLE_CELL_SELECTOR = 'td[data-table-row-start][data-table-row-end]'; + +export interface TableRowRange { + rowStart: number; + rowEnd: number; +} + +export function tableRowRangesIntersect(left: TableRowRange, right: TableRowRange) { + return left.rowStart <= right.rowEnd && right.rowStart <= left.rowEnd; +} + +/** Applies hover feedback to cells intersecting the hovered cell's original row span. */ +export function TableHoverTable(props: ComponentPropsWithoutRef<'table'>) { + const { onMouseOver, onMouseLeave, ...rest } = props; + const hoveredCellRef = useRef(null); + const highlightedCellsRef = useRef>(new Set()); + + const clearHighlightedCells = () => { + for (const cell of highlightedCellsRef.current) { + cell.removeAttribute('data-table-hovered'); + } + highlightedCellsRef.current.clear(); + hoveredCellRef.current = null; + }; + + const handleMouseOver = (event: ReactMouseEvent) => { + onMouseOver?.(event); + + const target = event.target; + if (!(target instanceof Element)) { + clearHighlightedCells(); + return; + } + + const hoveredCell = target.closest(TABLE_CELL_SELECTOR); + if (!hoveredCell || !event.currentTarget.contains(hoveredCell)) { + clearHighlightedCells(); + return; + } + if (hoveredCellRef.current === hoveredCell) { + return; + } + + const hoveredRange = getTableCellRowRange(hoveredCell); + if (!hoveredRange) { + clearHighlightedCells(); + return; + } + + const highlightedCells = new Set(); + for (const cell of event.currentTarget.querySelectorAll( + TABLE_CELL_SELECTOR + )) { + const cellRange = getTableCellRowRange(cell); + const highlighted = + cellRange !== null && tableRowRangesIntersect(cellRange, hoveredRange); + cell.toggleAttribute('data-table-hovered', highlighted); + if (highlighted) { + highlightedCells.add(cell); + } + } + + for (const cell of highlightedCellsRef.current) { + if (!highlightedCells.has(cell)) { + cell.removeAttribute('data-table-hovered'); + } + } + + hoveredCellRef.current = hoveredCell; + highlightedCellsRef.current = highlightedCells; + }; + + const handleMouseLeave = (event: ReactMouseEvent) => { + onMouseLeave?.(event); + clearHighlightedCells(); + }; + + return ; +} + +function getTableCellRowRange(cell: HTMLTableCellElement): TableRowRange | null { + const rowStart = Number(cell.dataset.tableRowStart); + const rowEnd = Number(cell.dataset.tableRowEnd); + if (!Number.isInteger(rowStart) || !Number.isInteger(rowEnd) || rowEnd < rowStart) { + return null; + } + + return { rowStart, rowEnd }; +} diff --git a/packages/gitbook/src/components/DocumentView/Table/TableSearch.tsx b/packages/gitbook/src/components/DocumentView/Table/TableSearch.tsx index 0eea01169..b8f6c5901 100644 --- a/packages/gitbook/src/components/DocumentView/Table/TableSearch.tsx +++ b/packages/gitbook/src/components/DocumentView/Table/TableSearch.tsx @@ -5,7 +5,11 @@ import React from 'react'; import { Icon } from '@gitbook/icons'; import type { TableCheckboxColumn, TableSelectColumn } from './search'; -import { type SelectedOptions, recordMatches } from './searchMatch'; +import { + type SelectedOptions, + type TableSearchRecordData, + getVisibleTableRecordIds, +} from './searchMatch'; import { Button, Checkbox, DropdownMenu, DropdownMenuItem, Input } from '@/components/primitives'; import { tString, useLanguage } from '@/intl/client'; import { type ClassValue, tcls } from '@/lib/tailwind'; @@ -18,15 +22,6 @@ import { type ClassValue, tcls } from '@/lib/tailwind'; * once and exposes the set of visible ids; each row/card just looks itself up by id. */ -/** Per-record matching data, computed on the server. */ -export interface TableSearchRecordData { - /** Record key, matching the `key` passed to ``. */ - id: string; - searchText: string; - selectValues?: Record; - checkboxValues?: Record; -} - type TableSearchContextValue = { query: string; setQuery: (query: string) => void; @@ -51,9 +46,10 @@ const TableSearchContext = React.createContext(n */ export function TableSearchProvider(props: { records?: TableSearchRecordData[]; + recordGroups?: readonly (readonly string[])[]; children: React.ReactNode; }) { - const { records = [] } = props; + const { records = [], recordGroups = [] } = props; const [query, setQuery] = React.useState(''); const [selectedOptions, setSelectedOptions] = React.useState(() => ({})); const [checkedColumns, setCheckedColumns] = React.useState>( @@ -91,32 +87,18 @@ export function TableSearchProvider(props: { }); }, []); - const hasActiveFilters = - query.trim() !== '' || Object.keys(selectedOptions).length > 0 || checkedColumns.size > 0; - // Match every record once, here, rather than in each row — rows just look themselves up by id. - const visibleIds = React.useMemo(() => { - if (!hasActiveFilters) { - return null; - } - - const ids = new Set(); - for (const record of records) { - if ( - recordMatches( - record.searchText, - record.selectValues, - record.checkboxValues, - query, - selectedOptions, - checkedColumns - ) - ) { - ids.add(record.id); - } - } - return ids; - }, [records, query, selectedOptions, checkedColumns, hasActiveFilters]); + const visibleIds = React.useMemo( + () => + getVisibleTableRecordIds({ + records, + recordGroups, + query, + selectedOptions, + checkedColumns, + }), + [records, recordGroups, query, selectedOptions, checkedColumns] + ); const isEmpty = visibleIds !== null && records.length > 0 && visibleIds.size === 0; @@ -310,3 +292,13 @@ export function TableSearchRecord(props: TableSearchRecordProps) { ); } + +/** Keeps a complete native table row group visible when any of its records matches. */ +export function TableSearchTableBody( + props: React.HTMLAttributes & { recordIds: readonly string[] } +) { + const { recordIds, children, ...rest } = props; + const { visibleIds } = useTableSearch(); + const matches = visibleIds === null || recordIds.some((recordId) => visibleIds.has(recordId)); + return matches ? {children} : null; +} diff --git a/packages/gitbook/src/components/DocumentView/Table/ViewGrid.tsx b/packages/gitbook/src/components/DocumentView/Table/ViewGrid.tsx index 89aa5671b..a8c34fc24 100644 --- a/packages/gitbook/src/components/DocumentView/Table/ViewGrid.tsx +++ b/packages/gitbook/src/components/DocumentView/Table/ViewGrid.tsx @@ -1,17 +1,15 @@ -import type { DocumentTableViewGrid } from '@gitbook/api'; - import { getColumnWidth, getViewGridLayout } from './layout'; import { RecordRow } from './RecordRow'; -import type { TableViewProps } from './Table'; +import type { TableGridViewProps } from './Table'; import { getColumnAlignment } from './utils'; import { tcls } from '@/lib/tailwind'; -interface ViewGridHeaderProps extends TableViewProps { +interface ViewGridHeaderProps extends TableGridViewProps { className?: string; tableClassName?: string; } -interface ViewGridProps extends TableViewProps { +interface ViewGridProps extends TableGridViewProps { tableClassName?: string; } diff --git a/packages/gitbook/src/components/DocumentView/Table/cellMerges.test.ts b/packages/gitbook/src/components/DocumentView/Table/cellMerges.test.ts new file mode 100644 index 000000000..2b0dac057 --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/Table/cellMerges.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from 'bun:test'; + +import type { DocumentBlockTable, DocumentTableDefinition } from '@gitbook/api'; + +import { createTableCellMergeLayout, getMergedCellWidth, getTableCellMerge } from './cellMerges'; + +const RECORD_ORDER = ['first', 'second', 'third']; +const COLUMN_ORDER = ['a', 'b', 'c']; + +function createBlock( + cellMerges?: unknown[], + options: { + columns?: string[]; + definitions?: Record; + recordOrder?: string[]; + viewType?: 'grid' | 'cards'; + } = {} +) { + const columns = options.columns ?? COLUMN_ORDER; + const recordOrder = options.recordOrder ?? RECORD_ORDER; + const definitions = + options.definitions ?? + Object.fromEntries(columns.map((column) => [column, createDefinition('text', column)])); + return { + data: { + view: + options.viewType === 'cards' + ? { type: 'cards', columns } + : { type: 'grid', columns }, + records: Object.fromEntries( + recordOrder.map((recordId, index) => [ + recordId, + { orderIndex: `${index}`, values: {} }, + ]) + ), + definition: definitions, + ...(cellMerges ? { cellMerges } : {}), + }, + } as unknown as DocumentBlockTable; +} + +function createDefinition(type: DocumentTableDefinition['type'], id: string) { + const base = { id, title: id }; + return type === 'text' + ? ({ ...base, type, textAlignment: 'left' } as DocumentTableDefinition) + : ({ ...base, type } as DocumentTableDefinition); +} + +describe('createTableCellMergeLayout', () => { + it('classifies horizontal and vertical Text and Number merges', () => { + for (const type of ['text', 'number'] as const) { + const layout = createTableCellMergeLayout( + createBlock([horizontalMerge('first', 'a', 2), verticalMerge('second', 'c', 2)], { + definitions: Object.fromEntries( + COLUMN_ORDER.map((column) => [column, createDefinition(type, column)]) + ), + }), + RECORD_ORDER + ); + + expect(getTableCellMerge(layout, 'first', 'a')).toMatchObject({ + isAnchor: true, + merge: { rowSpan: 1, colSpan: 2, columns: ['a', 'b'] }, + }); + expect(getTableCellMerge(layout, 'first', 'b')).toMatchObject({ isAnchor: false }); + expect(getTableCellMerge(layout, 'second', 'c')).toMatchObject({ + isAnchor: true, + merge: { rowSpan: 2, colSpan: 1, records: ['second', 'third'] }, + }); + expect(getTableCellMerge(layout, 'third', 'c')).toMatchObject({ isAnchor: false }); + expect(layout.recordGroups).toEqual([['first'], ['second', 'third']]); + expect(layout.hasVerticalMerges).toBe(true); + } + }); + + it('treats missing metadata and Cards as unmerged layouts', () => { + const missing = createTableCellMergeLayout(createBlock(), RECORD_ORDER); + const cards = createTableCellMergeLayout( + createBlock([horizontalMerge('first', 'a', 2)], { + viewType: 'cards', + }), + RECORD_ORDER + ); + + expect(missing.cells.size).toBe(0); + expect(missing.recordGroups).toEqual([['first'], ['second'], ['third']]); + expect(missing.hasVerticalMerges).toBe(false); + expect(cards.cells.size).toBe(0); + expect(cards.recordGroups).toEqual([]); + }); + + it.each([ + ['invalid shape', { anchor: 'first', rowSpan: 1, colSpan: 2 }], + ['single cell', { anchor: { record: 'first', column: 'a' }, rowSpan: 1, colSpan: 1 }], + ['unknown record', horizontalMerge('missing', 'a', 2)], + ['hidden column', horizontalMerge('first', 'hidden', 2)], + ['out-of-bounds span', horizontalMerge('first', 'c', 2)], + ['rectangle', { anchor: { record: 'first', column: 'a' }, rowSpan: 2, colSpan: 2 }], + ])('ignores malformed metadata: %s', (_name, merge) => { + const block = createBlock([merge], { + definitions: { + a: createDefinition('text', 'a'), + b: createDefinition('text', 'b'), + c: createDefinition('text', 'c'), + hidden: createDefinition('text', 'hidden'), + }, + }); + const layout = createTableCellMergeLayout(block, RECORD_ORDER); + + expect(layout.cells.size).toBe(0); + expect(layout.hasVerticalMerges).toBe(false); + }); + + it('drops every conflicting merge regardless of order while preserving unrelated merges', () => { + const horizontal = horizontalMerge('first', 'a', 2); + const vertical = verticalMerge('first', 'b', 2); + const duplicate = horizontalMerge('first', 'a', 2); + const unrelated = verticalMerge('second', 'c', 2); + + for (const cellMerges of [ + [horizontal, vertical, duplicate, unrelated], + [vertical, duplicate, horizontal, unrelated], + ]) { + const layout = createTableCellMergeLayout(createBlock(cellMerges), RECORD_ORDER); + + expect(getTableCellMerge(layout, 'first', 'a')).toBeUndefined(); + expect(getTableCellMerge(layout, 'first', 'b')).toBeUndefined(); + expect(getTableCellMerge(layout, 'second', 'b')).toBeUndefined(); + expect(getTableCellMerge(layout, 'second', 'c')).toMatchObject({ + isAnchor: true, + merge: { records: ['second', 'third'], columns: ['c'] }, + }); + expect(getTableCellMerge(layout, 'third', 'c')).toMatchObject({ isAnchor: false }); + expect(layout.recordGroups).toEqual([['first'], ['second', 'third']]); + } + }); + + it('partitions disjoint vertical merges and singleton records in record order', () => { + const recordOrder = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth']; + const layout = createTableCellMergeLayout( + createBlock([verticalMerge('fourth', 'b', 2), verticalMerge('first', 'a', 2)], { + recordOrder, + }), + recordOrder + ); + + expect(layout.recordGroups).toEqual([ + ['first', 'second'], + ['third'], + ['fourth', 'fifth'], + ['sixth'], + ]); + }); + + it('combines transitively connected vertical merges regardless of metadata order', () => { + for (const cellMerges of [ + [verticalMerge('first', 'a', 2), verticalMerge('second', 'b', 2)], + [verticalMerge('second', 'b', 2), verticalMerge('first', 'a', 2)], + ]) { + const layout = createTableCellMergeLayout(createBlock(cellMerges), RECORD_ORDER); + + expect(layout.recordGroups).toEqual([['first', 'second', 'third']]); + } + }); + + it('ignores merges for unsupported or mixed field types', () => { + const unsupportedDefinitions = { + a: createDefinition('checkbox', 'a'), + b: createDefinition('checkbox', 'b'), + }; + const unsupportedHorizontal = createTableCellMergeLayout( + createBlock([horizontalMerge('first', 'a', 2)], { + columns: ['a', 'b'], + definitions: unsupportedDefinitions, + }), + RECORD_ORDER + ); + const unsupportedVertical = createTableCellMergeLayout( + createBlock([verticalMerge('first', 'a', 2)], { + columns: ['a', 'b'], + definitions: unsupportedDefinitions, + }), + RECORD_ORDER + ); + const mixed = createTableCellMergeLayout( + createBlock([horizontalMerge('first', 'a', 2)], { + columns: ['a', 'b'], + definitions: { + a: createDefinition('text', 'a'), + b: createDefinition('number', 'b'), + }, + }), + RECORD_ORDER + ); + + expect(unsupportedHorizontal.cells.size).toBe(0); + expect(unsupportedVertical.cells.size).toBe(0); + expect(unsupportedVertical.hasVerticalMerges).toBe(false); + expect(mixed.cells.size).toBe(0); + }); +}); + +function horizontalMerge(record: string, column: string, colSpan: number) { + return { anchor: { record, column }, rowSpan: 1, colSpan }; +} + +function verticalMerge(record: string, column: string, rowSpan: number) { + return { anchor: { record, column }, rowSpan, colSpan: 1 }; +} + +describe('getMergedCellWidth', () => { + it('adds fixed and automatic column widths in CSS', () => { + expect(getMergedCellWidth(['120px', 'clamp(100px, calc(100% / 2), 100%)'])).toBe( + 'calc(120px + clamp(100px, calc(100% / 2), 100%))' + ); + }); +}); diff --git a/packages/gitbook/src/components/DocumentView/Table/cellMerges.ts b/packages/gitbook/src/components/DocumentView/Table/cellMerges.ts new file mode 100644 index 000000000..2eb065cb1 --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/Table/cellMerges.ts @@ -0,0 +1,232 @@ +import type { DocumentBlockTable } from '@gitbook/api'; + +/** + * Temporary compatibility type until the published `@gitbook/api` includes `cellMerges`. + */ +export interface TableCellMerge { + anchor: { + record: string; + column: string; + }; + rowSpan: number; + colSpan: number; +} + +type TableDataWithCellMerges = DocumentBlockTable['data'] & { + cellMerges?: TableCellMerge[]; +}; + +export interface ResolvedTableCellMerge { + records: readonly string[]; + columns: readonly string[]; + rowSpan: number; + colSpan: number; +} + +export interface TableCellMergeSlot { + merge: ResolvedTableCellMerge; + isAnchor: boolean; +} + +export interface TableCellMergeLayout { + cells: ReadonlyMap>; + recordGroups: readonly (readonly string[])[]; + hasVerticalMerges: boolean; +} + +/** + * Resolve valid merge metadata once for a rendered grid. + * + * Invalid external metadata is ignored before any cell can be classified as covered, ensuring a + * malformed merge always degrades to ordinary cells instead of hiding content. + */ +export function createTableCellMergeLayout( + block: DocumentBlockTable, + recordOrder: readonly string[] +): TableCellMergeLayout { + if (block.data.view.type !== 'grid') { + return { + cells: new Map(), + recordGroups: [], + hasVerticalMerges: false, + }; + } + + const columnOrder = block.data.view.columns; + const candidates: { merge: ResolvedTableCellMerge; cellKeys: string[] }[] = []; + const cells = new Map>(); + const verticalRecordGroups: string[][] = []; + + for (const candidate of getRawTableCellMerges(block)) { + const merge = resolveTableCellMerge(candidate, block, recordOrder, columnOrder); + if (!merge) continue; + + const cellKeys = merge.records.flatMap((recordId) => + merge.columns.map((columnId) => getCellKey(recordId, columnId)) + ); + candidates.push({ merge, cellKeys }); + } + + const cellOwnerCounts = new Map(); + for (const candidate of candidates) { + for (const cellKey of candidate.cellKeys) { + cellOwnerCounts.set(cellKey, (cellOwnerCounts.get(cellKey) ?? 0) + 1); + } + } + + for (const candidate of candidates) { + if (candidate.cellKeys.some((cellKey) => cellOwnerCounts.get(cellKey) !== 1)) continue; + const { merge } = candidate; + + for (const recordId of merge.records) { + let row = cells.get(recordId); + if (!row) { + row = new Map(); + cells.set(recordId, row); + } + + for (const columnId of merge.columns) { + row.set(columnId, { + merge, + isAnchor: recordId === merge.records[0] && columnId === merge.columns[0], + }); + } + } + + if (merge.rowSpan > 1) { + verticalRecordGroups.push([...merge.records]); + } + } + + return { + cells, + recordGroups: groupConnectedRecords(recordOrder, verticalRecordGroups), + hasVerticalMerges: verticalRecordGroups.length > 0, + }; +} + +/** Return the resolved merge classification for a logical table cell. */ +export function getTableCellMerge( + layout: TableCellMergeLayout, + recordId: string, + columnId: string +): TableCellMergeSlot | undefined { + return layout.cells.get(recordId)?.get(columnId); +} + +/** Combine the independent column widths used by a horizontal merged cell. */ +export function getMergedCellWidth(widths: string[]): string { + return widths.length === 1 && widths[0] ? widths[0] : `calc(${widths.join(' + ')})`; +} + +function getRawTableCellMerges(block: DocumentBlockTable): unknown[] { + const data = block.data as TableDataWithCellMerges; + return Array.isArray(data.cellMerges) ? data.cellMerges : []; +} + +function resolveTableCellMerge( + candidate: unknown, + block: DocumentBlockTable, + recordOrder: readonly string[], + columnOrder: readonly string[] +): ResolvedTableCellMerge | null { + if (!candidate || typeof candidate !== 'object') { + return null; + } + + const merge = candidate as { + anchor?: { record?: unknown; column?: unknown }; + rowSpan?: unknown; + colSpan?: unknown; + }; + if ( + typeof merge.anchor?.record !== 'string' || + typeof merge.anchor.column !== 'string' || + typeof merge.rowSpan !== 'number' || + !Number.isInteger(merge.rowSpan) || + merge.rowSpan < 1 || + typeof merge.colSpan !== 'number' || + !Number.isInteger(merge.colSpan) || + merge.colSpan < 1 || + !( + (merge.rowSpan === 1 && merge.colSpan >= 2) || + (merge.rowSpan >= 2 && merge.colSpan === 1) + ) + ) { + return null; + } + + const recordStart = recordOrder.indexOf(merge.anchor.record); + const columnStart = columnOrder.indexOf(merge.anchor.column); + if (recordStart < 0 || columnStart < 0) { + return null; + } + + const records = recordOrder.slice(recordStart, recordStart + merge.rowSpan); + const columns = columnOrder.slice(columnStart, columnStart + merge.colSpan); + const recordsValid = + records.length === merge.rowSpan && + records.every((recordId) => Boolean(block.data.records[recordId])); + const definitions = columns.map((columnId) => block.data.definition[columnId]); + const columnsValid = + columns.length === merge.colSpan && + definitions.every( + (definition) => definition?.type === 'text' || definition?.type === 'number' + ) && + definitions.every((definition) => definition?.type === definitions[0]?.type); + if (!recordsValid || !columnsValid) { + return null; + } + + return { + records: [...records], + columns: [...columns], + rowSpan: merge.rowSpan, + colSpan: merge.colSpan, + }; +} + +function getCellKey(recordId: string, columnId: string): string { + return `${recordId.length}:${recordId}${columnId}`; +} + +/** Partition records into ordered groups connected by one or more vertical merges. */ +function groupConnectedRecords( + recordOrder: readonly string[], + connectedRecordGroups: readonly (readonly string[])[] +): string[][] { + const parents = new Map(recordOrder.map((recordId) => [recordId, recordId])); + + const findRoot = (recordId: string): string => { + const parent = parents.get(recordId); + if (!parent || parent === recordId) { + return recordId; + } + + const root = findRoot(parent); + parents.set(recordId, root); + return root; + }; + + for (const group of connectedRecordGroups) { + const firstRecordId = group[0]; + if (!firstRecordId) continue; + + for (const recordId of group.slice(1)) { + parents.set(findRoot(recordId), findRoot(firstRecordId)); + } + } + + const groupedRecords = new Map(); + for (const recordId of recordOrder) { + const root = findRoot(recordId); + const group = groupedRecords.get(root); + if (group) { + group.push(recordId); + } else { + groupedRecords.set(root, [recordId]); + } + } + + return [...groupedRecords.values()]; +} diff --git a/packages/gitbook/src/components/DocumentView/Table/searchMatch.test.ts b/packages/gitbook/src/components/DocumentView/Table/searchMatch.test.ts index 54ea6a432..48d0b5fb2 100644 --- a/packages/gitbook/src/components/DocumentView/Table/searchMatch.test.ts +++ b/packages/gitbook/src/components/DocumentView/Table/searchMatch.test.ts @@ -1,10 +1,34 @@ import { describe, expect, it } from 'bun:test'; -import { type SelectedOptions, matchesText, recordMatches } from './searchMatch'; +import { + type SelectedOptions, + type TableSearchRecordData, + getVisibleTableRecordIds, + matchesText, + recordMatches, +} from './searchMatch'; const NO_OPTIONS: SelectedOptions = {}; const NO_CHECKBOXES: ReadonlySet = new Set(); +function visibleIds( + records: TableSearchRecordData[], + filters: { + query?: string; + selectedOptions?: SelectedOptions; + checkedColumns?: ReadonlySet; + }, + recordGroups: string[][] = [] +) { + return getVisibleTableRecordIds({ + records, + recordGroups, + query: filters.query ?? '', + selectedOptions: filters.selectedOptions ?? NO_OPTIONS, + checkedColumns: filters.checkedColumns ?? NO_CHECKBOXES, + }); +} + function match( record: { searchText?: string; @@ -149,3 +173,75 @@ describe('recordMatches', () => { expect(match({ ...record, checkboxValues: { featured: false } }, filters)).toBe(false); }); }); + +describe('getVisibleTableRecordIds', () => { + const records: TableSearchRecordData[] = [ + { + id: 'first', + searchText: 'Anchor value', + selectValues: { status: ['active'] }, + checkboxValues: { featured: false }, + }, + { + id: 'second', + searchText: 'Covered value', + selectValues: { status: ['archived'] }, + checkboxValues: { featured: true }, + }, + { + id: 'third', + searchText: 'Connected value', + selectValues: { status: ['pending'] }, + checkboxValues: { featured: false }, + }, + { + id: 'unrelated', + searchText: 'Unrelated value', + selectValues: { status: ['archived'] }, + checkboxValues: { featured: false }, + }, + ]; + const verticalGroup = [['first', 'second']]; + + it('returns null when no filter is active', () => { + expect(visibleIds(records, {}, verticalGroup)).toBeNull(); + }); + + it('keeps a complete vertical group when text matches its anchor', () => { + expect(visibleIds(records, { query: 'anchor' }, verticalGroup)).toEqual( + new Set(['first', 'second']) + ); + }); + + it('keeps a complete vertical group when select or checkbox filters match a grouped row', () => { + expect( + visibleIds(records, { selectedOptions: { status: new Set(['active']) } }, verticalGroup) + ).toEqual(new Set(['first', 'second'])); + expect( + visibleIds(records, { checkedColumns: new Set(['featured']) }, verticalGroup) + ).toEqual(new Set(['second', 'first'])); + }); + + it('excludes completely unmatched and disconnected groups', () => { + expect(visibleIds(records, { query: 'unrelated' }, verticalGroup)).toEqual( + new Set(['unrelated']) + ); + }); + + it('expands transitively connected groups in reversed metadata order without mutation', () => { + const groups = [ + ['second', 'third'], + ['first', 'second'], + ]; + const originalGroups = groups.map((group) => [...group]); + + expect(visibleIds(records, { query: 'anchor' }, groups)).toEqual( + new Set(['first', 'second', 'third']) + ); + expect(groups).toEqual(originalGroups); + }); + + it('leaves cards independently filtered when no merge groups are provided', () => { + expect(visibleIds(records, { query: 'anchor' })).toEqual(new Set(['first'])); + }); +}); diff --git a/packages/gitbook/src/components/DocumentView/Table/searchMatch.ts b/packages/gitbook/src/components/DocumentView/Table/searchMatch.ts index 57c22f957..a83cb6b18 100644 --- a/packages/gitbook/src/components/DocumentView/Table/searchMatch.ts +++ b/packages/gitbook/src/components/DocumentView/Table/searchMatch.ts @@ -8,6 +8,72 @@ /** Selected option values per select column, keyed by column id. */ export type SelectedOptions = Readonly>>; +/** Per-record data used by the client-side table search. */ +export interface TableSearchRecordData { + id: string; + searchText: string; + selectValues?: Record; + checkboxValues?: Record; +} + +/** + * Match records and expand matching vertical merge groups to a fixed point. + */ +export function getVisibleTableRecordIds({ + records, + recordGroups = [], + query, + selectedOptions, + checkedColumns, +}: { + records: readonly TableSearchRecordData[]; + recordGroups?: readonly (readonly string[])[]; + query: string; + selectedOptions: SelectedOptions; + checkedColumns: ReadonlySet; +}): ReadonlySet | null { + const hasActiveFilters = + query.trim() !== '' || Object.keys(selectedOptions).length > 0 || checkedColumns.size > 0; + if (!hasActiveFilters) { + return null; + } + + const visibleIds = new Set(); + for (const record of records) { + if ( + recordMatches( + record.searchText, + record.selectValues, + record.checkboxValues, + query, + selectedOptions, + checkedColumns + ) + ) { + visibleIds.add(record.id); + } + } + + let expanded = true; + while (expanded) { + expanded = false; + for (const group of recordGroups) { + if (!group.some((id) => visibleIds.has(id))) { + continue; + } + + for (const id of group) { + if (!visibleIds.has(id)) { + visibleIds.add(id); + expanded = true; + } + } + } + } + + return visibleIds; +} + /** * Whether a record passes the current filters. * From bcd1c41aa1d359b97f5404b9be176011fe69b377 Mon Sep 17 00:00:00 2001 From: Tomek Date: Thu, 3 Sep 2026 11:35:07 +0200 Subject: [PATCH 06/10] Scroll to top when selecting current page in search (#4578) Co-authored-by: Claude Haiku 4.5 --- .changeset/rnd-12673-search-current-page.md | 5 +++++ .../src/components/Search/SearchResults.tsx | 21 +++++++++++++------ 2 files changed, 20 insertions(+), 6 deletions(-) create mode 100644 .changeset/rnd-12673-search-current-page.md diff --git a/.changeset/rnd-12673-search-current-page.md b/.changeset/rnd-12673-search-current-page.md new file mode 100644 index 000000000..6ae529ef7 --- /dev/null +++ b/.changeset/rnd-12673-search-current-page.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Scroll to the top when selecting a search result for the page already being viewed. diff --git a/packages/gitbook/src/components/Search/SearchResults.tsx b/packages/gitbook/src/components/Search/SearchResults.tsx index 453a3c167..b7520a4ec 100644 --- a/packages/gitbook/src/components/Search/SearchResults.tsx +++ b/packages/gitbook/src/components/Search/SearchResults.tsx @@ -198,14 +198,23 @@ export const SearchResults = React.forwardRef(function SearchResults( const itemKey = getResultKey(item); const shouldAnimateItem = shouldAnimateResults || !seenResultKeys.current.has(itemKey); - const handleResultSelect = () => { + const handleResultSelect = ( + event: React.MouseEvent + ) => { + const isPageResult = + item.type === 'local-page' || + item.type === 'page' || + item.type === 'record'; + if ( - query && - siteSpaceId && - (item.type === 'local-page' || - item.type === 'page' || - item.type === 'record') + isPageResult && + !event.currentTarget.hash && + event.currentTarget.pathname === window.location.pathname ) { + window.scrollTo({ top: 0, behavior: 'smooth' }); + } + + if (query && siteSpaceId && isPageResult) { addRecentSearchQuery(siteSpaceId, query, 'search'); } From d7c867aa63d26d92bad13f0bb93b4fd86d866c19 Mon Sep 17 00:00:00 2001 From: Tomek Date: Thu, 3 Sep 2026 16:08:53 +0200 Subject: [PATCH 07/10] Fix MCP/Markdown copy confirmation closing dropdown instantly (#4579) Co-authored-by: Claude Sonnet 5 --- .changeset/mcp-copy-confirmation-dropdown.md | 5 ++++ .../components/PageActions/PageActions.tsx | 26 ++++++++++--------- 2 files changed, 19 insertions(+), 12 deletions(-) create mode 100644 .changeset/mcp-copy-confirmation-dropdown.md diff --git a/.changeset/mcp-copy-confirmation-dropdown.md b/.changeset/mcp-copy-confirmation-dropdown.md new file mode 100644 index 000000000..ad5fdbef0 --- /dev/null +++ b/.changeset/mcp-copy-confirmation-dropdown.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix the page-actions dropdown closing before the "Copied" confirmation could be shown when copying the MCP server URL, an MCP install command, or the page as Markdown. diff --git a/packages/gitbook/src/components/PageActions/PageActions.tsx b/packages/gitbook/src/components/PageActions/PageActions.tsx index 37eaa02ea..7e9ff1648 100644 --- a/packages/gitbook/src/components/PageActions/PageActions.tsx +++ b/packages/gitbook/src/components/PageActions/PageActions.tsx @@ -98,9 +98,14 @@ const createCopiedStateStore = () => { set({ copied: true }); timeoutRef = setTimeout(() => { - set({ copied: false }); onSuccess?.(); - timeoutRef = null; + + // Delay resetting the label past the dropdown's closing animation (`scaleOut`, + // 200ms) so the "Copied" label doesn't flip back while still visible mid-fade. + timeoutRef = setTimeout(() => { + set({ copied: false }); + timeoutRef = null; + }, 200); }, 1500); }, })); @@ -153,13 +158,7 @@ export function ActionCopyMarkdown(props: { return result; }; - const onClick = async (e: React.MouseEvent) => { - // Prevent default behavior for non-default actions to avoid closing the dropdown. - // This allows showing transient UI (e.g., a "copied" state) inside the menu item. - if (!isDefaultAction) { - e.preventDefault(); - } - + const onClick = async () => { copy(markdownCache.get(markdownPageURL) || (await fetchMarkdown()), { onSuccess: () => { // We close the dropdown menu if the action is a dropdown menu item and not the default action. @@ -179,6 +178,7 @@ export function ActionCopyMarkdown(props: { description={tString(language, 'copy_page_markdown')} onClick={onClick} loading={loading} + closeOnClick={false} /> ); } @@ -421,9 +421,7 @@ export function CopyToClipboard(props: { icon={copied ? 'check' : icon} label={copied ? tString(language, 'code_copied') : label} description={description} - onClick={(e) => { - e.preventDefault(); - + onClick={() => { copy(data, { onSuccess: () => { if (type === 'dropdown-menu-item') { @@ -432,6 +430,7 @@ export function CopyToClipboard(props: { }, }); }} + closeOnClick={false} /> ); } @@ -453,6 +452,7 @@ function PageActionWrapper(props: { target?: React.HTMLAttributeAnchorTarget; disabled?: boolean; loading?: boolean; + closeOnClick?: boolean; }) { const { type, @@ -465,6 +465,7 @@ function PageActionWrapper(props: { description, disabled, loading, + closeOnClick, } = props; if (type === 'button') { @@ -498,6 +499,7 @@ function PageActionWrapper(props: { target={target} onClick={onClick} disabled={disabled || loading} + closeOnClick={closeOnClick} >
{loading ? ( From 2ff77e15b52fd292c3bc03fa1641ead42a4ff6af Mon Sep 17 00:00:00 2001 From: "Nolann B." <100787331+nolannbiron@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:17:00 +0200 Subject: [PATCH 08/10] Attribute a markdown request from the page actions menu (#4581) --- .changeset/markdown-page-action-source.md | 5 +++++ bun.lock | 4 ++-- package.json | 2 +- .../gitbook/src/components/PageActions/PageActions.tsx | 10 +++++++--- packages/gitbook/src/middleware.ts | 9 +++++++++ 5 files changed, 24 insertions(+), 6 deletions(-) create mode 100644 .changeset/markdown-page-action-source.md diff --git a/.changeset/markdown-page-action-source.md b/.changeset/markdown-page-action-source.md new file mode 100644 index 000000000..927930487 --- /dev/null +++ b/.changeset/markdown-page-action-source.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Bump `@gitbook/api` to 0.199.0, and record a markdown request made from the page actions menu as a page action rather than an agent request. diff --git a/bun.lock b/bun.lock index d354000e9..5978e4818 100644 --- a/bun.lock +++ b/bun.lock @@ -354,7 +354,7 @@ }, "catalog": { "@base-ui/react": "^1.7.0", - "@gitbook/api": "0.198.0", + "@gitbook/api": "0.199.0", "@scalar/api-client-react": "^1.3.46", "@tsconfig/node20": "^20.1.6", "@tsconfig/strictest": "^2.0.6", @@ -726,7 +726,7 @@ "@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.2.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.2.0" } }, "sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q=="], - "@gitbook/api": ["@gitbook/api@0.198.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-ZGXqYip6YFsCM5rqlJ+n8/Esw1bMNK4whqiMoWMeQgMKEX5YXVQaBuVRxU1jDEI49kup+T56xPq3I9g6Wcayng=="], + "@gitbook/api": ["@gitbook/api@0.199.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-yLxkSTXlGk7jbtThV2vpnTfqZTq6yLgMJt6jhDGqOmP0LsYAVYYtg8NPf2tXp37aF46m3Z0D908wfSQyu374cg=="], "@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"], diff --git a/package.json b/package.json index 9747484e5..ccac0c529 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "@tsconfig/strictest": "^2.0.6", "@tsconfig/node20": "^20.1.6", "@base-ui/react": "^1.7.0", - "@gitbook/api": "0.198.0", + "@gitbook/api": "0.199.0", "@scalar/api-client-react": "^1.3.46", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", diff --git a/packages/gitbook/src/components/PageActions/PageActions.tsx b/packages/gitbook/src/components/PageActions/PageActions.tsx index 7e9ff1648..df50737f0 100644 --- a/packages/gitbook/src/components/PageActions/PageActions.tsx +++ b/packages/gitbook/src/components/PageActions/PageActions.tsx @@ -5,7 +5,7 @@ import QuickLRU from 'quick-lru'; import React from 'react'; import { createStore, useStore } from 'zustand'; -import type { GitSyncState } from '@gitbook/api'; +import { type GitSyncState, SiteInsightsMarkdownSource } from '@gitbook/api'; import { Icon, type IconName, IconStyle } from '@gitbook/icons'; import { useAIChatController, useAIChatState } from '@/components/AI'; @@ -125,6 +125,10 @@ function useCopiedStore(stateKey: string) { return useStore(getOrCreateCopiedStoreByKey(stateKey)); } +function getReaderMarkdownURL(markdownPageURL: string) { + return `${markdownPageURL}?displayAgentInstructions=false&markdownSource=${SiteInsightsMarkdownSource.PageAction}`; +} + /** * Cache for the markdown version of the page. */ @@ -149,7 +153,7 @@ export function ActionCopyMarkdown(props: { const fetchMarkdown = async () => { setLoading(true); - const humanURL = `${markdownPageURL}?displayAgentInstructions=false`; + const humanURL = getReaderMarkdownURL(markdownPageURL); const result = await fetch(humanURL).then((res) => res.text()); markdownCache.set(markdownPageURL, result); @@ -196,7 +200,7 @@ export function ActionViewAsMarkdown(props: { markdownPageURL: string; type: Pag icon="markdown" label={tString(language, 'view_page_markdown')} description={tString(language, 'view_page_plaintext')} - href={`${markdownPageURL}?displayAgentInstructions=false`} + href={getReaderMarkdownURL(markdownPageURL)} /> ); } diff --git a/packages/gitbook/src/middleware.ts b/packages/gitbook/src/middleware.ts index 57bba5dea..d8c760d61 100644 --- a/packages/gitbook/src/middleware.ts +++ b/packages/gitbook/src/middleware.ts @@ -13,6 +13,7 @@ import { SiteInsightsDisplayContext, type SiteInsightsEventLocation, SiteInsightsLLMSVariant, + SiteInsightsMarkdownSource, } from '@gitbook/api'; import { @@ -533,6 +534,9 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) { if (rewrittenURL.searchParams.has('displayAgentInstructions')) { rewrittenURL.searchParams.delete('displayAgentInstructions'); } + if (rewrittenURL.searchParams.has('markdownSource')) { + rewrittenURL.searchParams.delete('markdownSource'); + } const response = NextResponse.rewrite(rewrittenURL, { request: { @@ -897,6 +901,10 @@ function encodePathInSiteContent( // It is encoded as a second path segment (the route is statically rendered, so it can't // read query params at runtime — the question is path-encoded for the same reason). const goal = searchParams.get('goal'); + // Validated: this is user input going into insights. + const markdownSource = Object.values(SiteInsightsMarkdownSource).find( + (source) => source === searchParams.get('markdownSource') + ); return { pathname: typeof ask === 'string' @@ -922,6 +930,7 @@ function encodePathInSiteContent( : [ { type: 'page_markdown_request', + ...(markdownSource ? { markdownSource } : {}), location: { displayContext: SiteInsightsDisplayContext.Server, }, From 46b3a18b25b2dbb282efc77e79c7169d1a6d90c4 Mon Sep 17 00:00:00 2001 From: Brett Jephson Date: Fri, 4 Sep 2026 16:20:52 +0100 Subject: [PATCH 09/10] Fix nested select css (#4583) Co-authored-by: Claude Opus 5 (1M context) --- .changeset/tidy-mice-eat.md | 5 + packages/gitbook/e2e/select.spec.ts | 96 +++++++++++++++++++ packages/gitbook/src/lib/select/constants.ts | 2 + .../src/lib/select/generateSelectCSS.ts | 18 ++-- 4 files changed, 114 insertions(+), 7 deletions(-) create mode 100644 .changeset/tidy-mice-eat.md diff --git a/.changeset/tidy-mice-eat.md b/.changeset/tidy-mice-eat.md new file mode 100644 index 000000000..e56ca83b4 --- /dev/null +++ b/.changeset/tidy-mice-eat.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix tabs nested inside another tab group rendering an empty body once a tab in the outer group was selected. diff --git a/packages/gitbook/e2e/select.spec.ts b/packages/gitbook/e2e/select.spec.ts index 1417f87e0..ea9b06df4 100644 --- a/packages/gitbook/e2e/select.spec.ts +++ b/packages/gitbook/e2e/select.spec.ts @@ -236,3 +236,99 @@ test.describe('select syncing across groups (click-driven)', () => { await expectGroupShows(page, 'b', other, 'go'); }); }); + +interface NestedSpec { + outer: string[]; + inner: string[]; + /** Which of the outer options hosts the nested group. */ + host: string; + /** + * Emit the nested group's stylesheet before the outer one, as happens when a group with the + * same option set appears earlier on the page and its deduped sheet lands in `` first. + */ + innerStyleFirst?: boolean; +} + +/** + * Render a group nested inside one of another group's panes, mirroring the DOM `DynamicTabs` + * produces: panes are direct children of the element carrying the set class, and a pane's body is + * wrapped in a padding div before the nested group. + */ +async function renderNestedGroups(page: Page, spec: NestedSpec) { + const { outer, inner, host, innerStyleFirst = false } = spec; + const outerScope = selectSetClassName(outer); + const innerScope = selectSetClassName(inner); + + const innerPanes = inner + .map( + (slug, index) => + `
${slug}
` + ) + .join(''); + const innerGroup = `
${innerPanes}
`; + + const outerPanes = outer + .map( + (slug, index) => + `
${slug}${slug === host ? innerGroup : ''}
` + ) + .join(''); + + const styles = [generateSelectCSS(outer), generateSelectCSS(inner)]; + if (innerStyleFirst) { + styles.reverse(); + } + + await page.setContent( + `${styles.map((css) => ``).join('')}
${outerPanes}
` + ); +} + +/** + * A group's stylesheet must resolve only its own panes. Because every pane of a nested group is also + * a descendant of the outer group, a sheet that reached descendants instead of children would hide + * the nested panes whenever an outer option was active, leaving the nested tab bar with an empty body. + */ +test.describe('select CSS visibility in nested groups', () => { + const outer = ['macos', 'windows']; + const inner = ['npm', 'yarn']; + + test('shows both defaults when nothing is selected', async ({ page }) => { + await renderNestedGroups(page, { outer, inner, host: 'macos' }); + await expect(page.getByTestId('outer-pane-macos')).toBeVisible(); + await expect(page.getByTestId('inner-pane-npm')).toBeVisible(); + await expect(page.getByTestId('inner-pane-yarn')).toBeHidden(); + }); + + test('keeps the nested group resolved when an outer option is activated', async ({ page }) => { + await renderNestedGroups(page, { outer, inner, host: 'macos' }); + await applySelection(page, ['macos']); + await expect(page.getByTestId('outer-pane-macos')).toBeVisible(); + await expect(page.getByTestId('inner-pane-npm')).toBeVisible(); + await expect(page.getByTestId('inner-pane-yarn')).toBeHidden(); + }); + + test('resolves a nested group hosted by a non-default outer option', async ({ page }) => { + await renderNestedGroups(page, { outer, inner, host: 'windows' }); + await applySelection(page, ['windows']); + await expect(page.getByTestId('outer-pane-windows')).toBeVisible(); + await expect(page.getByTestId('inner-pane-npm')).toBeVisible(); + await expect(page.getByTestId('inner-pane-yarn')).toBeHidden(); + }); + + test('resolves each group against its own options', async ({ page }) => { + await renderNestedGroups(page, { outer, inner, host: 'macos' }); + await applySelection(page, ['yarn', 'macos']); + await expect(page.getByTestId('outer-pane-macos')).toBeVisible(); + await expect(page.getByTestId('inner-pane-yarn')).toBeVisible(); + await expect(page.getByTestId('inner-pane-npm')).toBeHidden(); + }); + + test('resolves the same way whichever stylesheet comes first', async ({ page }) => { + await renderNestedGroups(page, { outer, inner, host: 'macos', innerStyleFirst: true }); + await applySelection(page, ['yarn', 'macos']); + await expect(page.getByTestId('outer-pane-macos')).toBeVisible(); + await expect(page.getByTestId('inner-pane-yarn')).toBeVisible(); + await expect(page.getByTestId('inner-pane-npm')).toBeHidden(); + }); +}); diff --git a/packages/gitbook/src/lib/select/constants.ts b/packages/gitbook/src/lib/select/constants.ts index c841c5979..82f17288a 100644 --- a/packages/gitbook/src/lib/select/constants.ts +++ b/packages/gitbook/src/lib/select/constants.ts @@ -22,6 +22,8 @@ export function selectRankAttribute(rank: number): string { } // DOM contract applied by consumer blocks (tabs, cards, …) and read by the generated CSS. +// Option panes must be direct children of the element carrying the set class: the generated +// selectors use a child combinator, so a group never resolves the panes of a group nested in it. /** Marks a group of mutually-exclusive options (e.g. a tab group). */ export const SELECT_GROUP_ATTR = 'data-select-group'; diff --git a/packages/gitbook/src/lib/select/generateSelectCSS.ts b/packages/gitbook/src/lib/select/generateSelectCSS.ts index 5b1e44ac2..6ea8a2339 100644 --- a/packages/gitbook/src/lib/select/generateSelectCSS.ts +++ b/packages/gitbook/src/lib/select/generateSelectCSS.ts @@ -73,6 +73,10 @@ function escapeCssString(value: string): string { * chains. `depth` must cover every rank the store can produce — visibility is CSS-only, so a winner * beyond `depth` would fall back to its default — hence it defaults to {@link SELECT_LIST_CAP}. * + * Every rule matches `& > …` rather than a descendant: a nested group's panes are also + * descendants of the outer group, so a descendant combinator would let the outer sheet hide them. + * The child combinator adds no specificity, leaving the source-order priority above intact. + * * Returns `''` for an empty/degenerate set. */ export function generateSelectCSS(candidateSlugs: string[], depth = SELECT_LIST_CAP): string { @@ -86,20 +90,20 @@ export function generateSelectCSS(candidateSlugs: string[], depth = SELECT_LIST_ // All rules nest under the scope class; `&` stands in for it (see nesting note above). const rules: string[] = [ // Hide every option, then reveal the default. Both are overridden below when a slug is active. - `${option}{display:none}`, - `[${SELECT_DEFAULT_ATTR}]{display:block}`, + `& > ${option}{display:none}`, + `& > [${SELECT_DEFAULT_ATTR}]{display:block}`, ]; for (let rank = depth - 1; rank >= 0; rank--) { const attr = selectRankAttribute(rank); const anyAtRank = slugs.map((slug) => `[${attr}="${escapeCssString(slug)}"]`).join(','); // When any of the set's options sits at this rank, hide the group's panes... - rules.push(`html:is(${anyAtRank}) & ${option}{display:none}`); + rules.push(`html:is(${anyAtRank}) & > ${option}{display:none}`); // ...then reveal whichever one matches (correlated, so a per-option list). const show = slugs .map((slug) => { const value = escapeCssString(slug); - return `html[${attr}="${value}"] & [${SELECT_OPTION_ATTR}="${value}"]`; + return `html[${attr}="${value}"] & > [${SELECT_OPTION_ATTR}="${value}"]`; }) .join(','); rules.push(`${show}{display:block}`); @@ -112,14 +116,14 @@ export function generateSelectCSS(candidateSlugs: string[], depth = SELECT_LIST_ for (const slug of slugs) { const value = escapeCssString(slug); const pane = `[${SELECT_OPTION_ATTR}="${value}"]`; - rules.push(`html & ${pane} ~ ${pane}{display:none}`); + rules.push(`html & > ${pane} ~ ${pane}{display:none}`); } // A client click can override that first-match default: it pins the picked pane and unpins its // same-slug siblings so the visitor sees exactly the duplicate they clicked (reload reverts to // first-match since these attributes aren't persisted). Emitted last to win at equal specificity. - rules.push(`html & ${option}[${SELECT_PINNED_ATTR}]{display:block}`); - rules.push(`html & ${option}[${SELECT_UNPINNED_ATTR}]{display:none}`); + rules.push(`html & > ${option}[${SELECT_PINNED_ATTR}]{display:block}`); + rules.push(`html & > ${option}[${SELECT_UNPINNED_ATTR}]{display:none}`); return `.${selectSetClassName(slugs)}{${rules.join('')}}`; } From 1f250d1fa83237abf319522841dd8a48299ddf1e Mon Sep 17 00:00:00 2001 From: Tomek Date: Mon, 7 Sep 2026 09:31:50 +0200 Subject: [PATCH 10/10] Add site structure e2e tests for external links (#4584) --- .changeset/rnd-12681-site-structure-e2e.md | 5 + packages/gitbook/e2e/internal.spec.ts | 144 +++++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 .changeset/rnd-12681-site-structure-e2e.md diff --git a/.changeset/rnd-12681-site-structure-e2e.md b/.changeset/rnd-12681-site-structure-e2e.md new file mode 100644 index 000000000..815c0e4e5 --- /dev/null +++ b/.changeset/rnd-12681-site-structure-e2e.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Add end-to-end coverage for root and nested external links in site section navigation. diff --git a/packages/gitbook/e2e/internal.spec.ts b/packages/gitbook/e2e/internal.spec.ts index a5d5cd084..c45c7411b 100644 --- a/packages/gitbook/e2e/internal.spec.ts +++ b/packages/gitbook/e2e/internal.spec.ts @@ -707,6 +707,150 @@ const testCases: TestsCase[] = [ await page.waitForURL((url) => url.pathname.includes('/sections/sections-4')); }, }, + { + name: 'Root external link renders in the configured position', + url: '', + screenshot: false, + run: async (page) => { + await waitForHydration(page); + const rootSections = page.locator('[data-gb-sections]'); + const rootItems = rootSections.locator(':scope > li'); + + await expect(rootItems).toHaveCount(4); + await expect(rootItems.nth(0)).toContainText('Home'); + await expect(rootItems.nth(1)).toContainText('Test Section Group 1'); + await expect(rootItems.nth(2)).toContainText('Test Section Group 2'); + await expect(rootItems.last()).toContainText('Gitbook Docs'); + await expect( + rootSections.getByRole('link', { name: 'Gitbook Docs' }) + ).toBeVisible(); + }, + }, + { + name: 'Root external link has the configured contract', + url: '', + screenshot: false, + run: async (page) => { + await waitForHydration(page); + const rootLink = page + .locator('[data-gb-sections]') + .getByRole('link', { name: 'Gitbook Docs' }); + + await expect(rootLink).toBeVisible(); + await expect(rootLink).toHaveAttribute('href', 'https://gitbook.com/docs'); + await expect(rootLink).not.toHaveAttribute('target'); + await expect(rootLink).not.toHaveAttribute('rel'); + await expect(rootLink).toHaveAttribute('data-active', 'false'); + await expect(rootLink).not.toHaveAttribute('aria-current'); + }, + }, + { + name: 'Nested external link renders in the configured position', + url: '', + screenshot: false, + run: async (page) => { + await waitForHydration(page); + await page + .locator('[data-gb-sections]') + .getByRole('button', { name: 'Test Section Group 2' }) + .hover(); + + const nestedLink = page.getByRole('link', { name: 'Gitbook Site' }); + await expect(nestedLink).toBeVisible(); + + const nestedItems = nestedLink + .locator('xpath=ancestor::ul[1]') + .locator(':scope > li'); + await expect(nestedItems).toHaveCount(3); + await expect(nestedItems.nth(0)).toContainText('Section C'); + await expect(nestedItems.nth(1)).toContainText('Section with longer title'); + await expect(nestedItems.last()).toContainText('Gitbook Site'); + }, + }, + { + name: 'Nested external link has the configured contract', + url: '', + screenshot: false, + run: async (page) => { + await waitForHydration(page); + await page + .locator('[data-gb-sections]') + .getByRole('button', { name: 'Test Section Group 2' }) + .hover(); + + const nestedLink = page.getByRole('link', { name: 'Gitbook Site' }); + await expect(nestedLink).toBeVisible(); + await expect(nestedLink).toHaveAttribute('href', 'https://gitbook.com'); + await expect(nestedLink).not.toHaveAttribute('target'); + await expect(nestedLink).not.toHaveAttribute('rel'); + await expect(nestedLink).not.toHaveAttribute('aria-current'); + }, + }, + { + name: 'External links use the configured window open behavior', + url: '', + screenshot: false, + run: async (page) => { + await waitForHydration(page); + + const windowOpenCalls: { + url: string; + target: string; + features: string | undefined; + }[] = []; + await page.exposeFunction( + 'recordExternalWindowOpen', + (url: string, target: string, features?: string) => { + windowOpenCalls.push({ url, target, features }); + } + ); + await page.evaluate(() => { + const recordExternalWindowOpen = ( + window as unknown as { + recordExternalWindowOpen: ( + url: string, + target: string, + features?: string + ) => void; + } + ).recordExternalWindowOpen; + window.open = ((url, target, features) => { + void recordExternalWindowOpen( + url?.toString() ?? '', + target ?? '', + features + ); + return null; + }) as typeof window.open; + }); + + const initialURL = page.url(); + await page + .locator('[data-gb-sections]') + .getByRole('link', { name: 'Gitbook Docs' }) + .click(); + await expect.poll(() => windowOpenCalls.length).toBe(1); + expect(windowOpenCalls[0]).toEqual({ + url: 'https://gitbook.com/docs', + target: '_self', + features: undefined, + }); + await expect(page).toHaveURL(initialURL); + + await page + .locator('[data-gb-sections]') + .getByRole('button', { name: 'Test Section Group 2' }) + .hover(); + await page.getByRole('link', { name: 'Gitbook Site' }).click(); + await expect.poll(() => windowOpenCalls.length).toBe(2); + expect(windowOpenCalls[1]).toEqual({ + url: 'https://gitbook.com', + target: '_self', + features: undefined, + }); + await expect(page).toHaveURL(initialURL); + }, + }, ], }, {