Enhance StructurePreview navigation by adding sectionId handling and updating state management

This commit is contained in:
Nicolas Dorseuil
2026-06-17 10:30:14 +02:00
parent 6f63c00300
commit 53ec8b9f29
5 changed files with 183 additions and 8 deletions
@@ -157,6 +157,11 @@ export function SiteSectionTabs(props: {
? structureItem.url
: undefined
}
sectionId={
structureItem.object === 'site-section'
? structureItem.id
: undefined
}
isActive={isActive}
title={title}
icon={icon ? (icon as IconName) : undefined}
@@ -233,10 +238,10 @@ function useNavigationMenuViewportOffset(args: {
* A tab representing a section or section group
*/
const SectionTab = React.forwardRef(function SectionTab(
props: { isActive: boolean; title: string; icon?: IconName; url?: string },
props: { isActive: boolean; title: string; icon?: IconName; url?: string; sectionId?: string },
ref: React.Ref<HTMLAnchorElement>
) {
const { isActive, title, icon, url, ...rest } = props;
const { isActive, title, icon, url, sectionId, ...rest } = props;
const isGroup = url === undefined;
return (
<Button
@@ -248,6 +253,7 @@ const SectionTab = React.forwardRef(function SectionTab(
label={title}
trailing={isGroup ? <ToggleChevron /> : null}
active={isActive}
data-gb-site-section-id={sectionId}
className={tcls(
'group/dropdown relative my-1.5 overflow-visible',
isActive
@@ -356,6 +362,7 @@ function SectionGroupTile(props: {
<li className="group/section-tile flex w-full min-w-0 shrink-0 grow md:max-w-[var(--site-section-column-width)]">
<Link
href={url}
data-gb-site-section-id={child.id}
className={tcls(
'grow circular-corners:rounded-2xl rounded-corners:rounded-lg px-2.5 py-1.5 transition-colors',
isActive
@@ -49,11 +49,16 @@ import { SearchHeaderInput } from '../Search';
import { CONTAINER_STYLE } from '../layout';
import { Button, type ButtonProps, ToggleChevron } from '../primitives';
import { DropdownMenu, DropdownMenuItem } from '../primitives/DropdownMenu';
import { SOCIAL_PLATFORM_ICONS, isStructurePreviewMessage } from './state';
import {
SOCIAL_PLATFORM_ICONS,
isStructurePreviewMessage,
selectStructurePreviewSection,
} from './state';
import type {
PreviewContentLink,
PreviewDropdownSpace,
PreviewHeaderLink,
StructurePreviewNavigationMessage,
StructurePreviewSnapshot,
} from './types';
@@ -79,19 +84,47 @@ export function StructurePreview(props: { initialSnapshot: StructurePreviewSnaps
return () => window.removeEventListener('message', handleMessage);
}, []);
const postNavigationChange = (sectionId: string) => {
const message: StructurePreviewNavigationMessage = {
type: 'gitbook.structure.navigate',
payload: { sectionId },
};
window.parent.postMessage(message, window.location.origin);
};
const preventNavigation = (event: React.MouseEvent<HTMLElement>) => {
const target = event.target;
if (!(target instanceof Element)) {
return;
return null;
}
const anchor = target.closest('a');
if (!anchor) {
return;
return null;
}
event.preventDefault();
event.stopPropagation();
return anchor;
};
const fakeSectionNavigation = (event: React.MouseEvent<HTMLElement>) => {
const anchor = preventNavigation(event);
const sectionId = anchor?.getAttribute('data-gb-site-section-id');
if (!sectionId) {
return;
}
setSnapshot((currentSnapshot) => {
const nextSnapshot = selectStructurePreviewSection(currentSnapshot, sectionId);
if (nextSnapshot !== currentSnapshot) {
postNavigationChange(sectionId);
}
return nextSnapshot;
});
};
return (
@@ -99,7 +132,7 @@ export function StructurePreview(props: { initialSnapshot: StructurePreviewSnaps
data-gb-structure-preview
data-viewport-mode="desktop"
className="site-background min-h-screen min-w-[1024px] overflow-hidden"
onClickCapture={preventNavigation}
onClickCapture={fakeSectionNavigation}
onAuxClickCapture={preventNavigation}
>
<StructurePreviewHeader snapshot={snapshot} />
@@ -3,10 +3,10 @@ import { describe, expect, it } from 'bun:test';
import { getStructurePreviewSnapshot } from '@/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/structure/snapshot';
import { languages } from '@/intl/translations';
import type { GitBookSiteContext } from '@/lib/context';
import { defaultCustomization } from '@/lib/utils';
import { defaultCustomization, findSectionInGroup } from '@/lib/utils';
import { SiteSocialAccountPlatform, TranslationLanguage } from '@gitbook/api';
import { isStructurePreviewMessage } from './state';
import { isStructurePreviewMessage, selectStructurePreviewSection } from './state';
function createContext(overrides: Partial<GitBookSiteContext> = {}): GitBookSiteContext {
const siteSpace = {
@@ -58,6 +58,12 @@ describe('structure preview state', () => {
expect('visibleSiteSpaces' in snapshot).toBe(false);
expect(isStructurePreviewMessage({ type: 'gitbook.structure.update' })).toBe(false);
expect(isStructurePreviewMessage({ type: 'other', payload: snapshot })).toBe(false);
expect(
isStructurePreviewMessage({
type: 'gitbook.structure.navigate',
payload: { sectionId: 'section-1' },
})
).toBe(false);
});
it('stores pre-encoded section structures with inert URLs', () => {
@@ -176,4 +182,81 @@ describe('structure preview state', () => {
{ platform: SiteSocialAccountPlatform.Github, handle: 'gitbook' },
]);
});
it('selects a top-level section in the local snapshot', () => {
const snapshot = createSnapshotWithSections();
const nextSnapshot = selectStructurePreviewSection(snapshot, 'reference');
expect(nextSnapshot).not.toBe(snapshot);
expect(nextSnapshot.sections?.current.id).toBe('reference');
expect(nextSnapshot.sections?.current.title).toBe('Reference');
});
it('selects a nested section in the local snapshot', () => {
const snapshot = createSnapshotWithSections();
const nextSnapshot = selectStructurePreviewSection(snapshot, 'api');
const currentSection = nextSnapshot.sections?.current;
const group = nextSnapshot.sections?.list[1];
expect(currentSection?.id).toBe('api');
expect(group?.object).toBe('site-section-group');
if (!currentSection || group?.object !== 'site-section-group') {
throw new Error('Expected a nested section inside a section group');
}
expect(findSectionInGroup(group, currentSection.id)?.id).toBe('api');
});
it('keeps the current snapshot when selecting an unknown section', () => {
const snapshot = createSnapshotWithSections();
const nextSnapshot = selectStructurePreviewSection(snapshot, 'missing');
expect(nextSnapshot).toBe(snapshot);
expect(nextSnapshot.sections?.current.id).toBe('intro');
});
it('keeps snapshots without sections unchanged', () => {
const snapshot = getStructurePreviewSnapshot(createContext());
const nextSnapshot = selectStructurePreviewSection(snapshot, 'reference');
expect(nextSnapshot).toBe(snapshot);
expect(nextSnapshot.sections).toBeNull();
});
});
function createSnapshotWithSections() {
const intro = createSection('intro', 'Intro');
const reference = createSection('reference', 'Reference');
const api = createSection('api', 'API');
return getStructurePreviewSnapshot(
createContext({
sections: {
list: [
intro,
{
object: 'site-section-group',
id: 'developers',
title: 'Developers',
children: [api],
},
reference,
],
current: intro,
},
} as unknown as Partial<GitBookSiteContext>)
);
}
function createSection(id: string, title: string) {
return {
object: 'site-section',
id,
title,
description: '',
path: id,
default: false,
siteSpaces: [],
urls: {},
};
}
@@ -1,6 +1,7 @@
import type { SiteSocialAccountPlatform } from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
import type { ClientSiteSection, ClientSiteSectionGroup } from '../SiteSections';
import type { StructurePreviewMessage, StructurePreviewSnapshot } from './types';
export function isStructurePreviewMessage(value: unknown): value is StructurePreviewMessage {
@@ -39,6 +40,50 @@ export function isStructurePreviewSnapshot(value: unknown): value is StructurePr
);
}
export function selectStructurePreviewSection(
snapshot: StructurePreviewSnapshot,
sectionId: string
): StructurePreviewSnapshot {
const sections = snapshot.sections;
if (!sections || sections.current.id === sectionId) {
return snapshot;
}
const selectedSection = findPreviewSection(sections.list, sectionId);
if (!selectedSection) {
return snapshot;
}
return {
...snapshot,
sections: {
...sections,
current: selectedSection,
},
};
}
function findPreviewSection(
items: (ClientSiteSection | ClientSiteSectionGroup)[],
sectionId: string
): ClientSiteSection | null {
for (const item of items) {
if (item.object === 'site-section') {
if (item.id === sectionId) {
return item;
}
continue;
}
const childSection = findPreviewSection(item.children, sectionId);
if (childSection) {
return childSection;
}
}
return null;
}
export const SOCIAL_PLATFORM_ICONS: Partial<Record<SiteSocialAccountPlatform, IconName>> = {
twitter: 'x-twitter',
instagram: 'instagram',
@@ -80,3 +80,10 @@ export type StructurePreviewMessage = {
type: 'gitbook.structure.update';
payload: StructurePreviewSnapshot;
};
export type StructurePreviewNavigationMessage = {
type: 'gitbook.structure.navigate';
payload: {
sectionId: string;
};
};