From 3394efc2ba9da790400e0859599ab24f989000ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Berg=C3=A9?= Date: Thu, 16 Oct 2025 17:56:37 +0200 Subject: [PATCH 1/6] Improve tabs --- .../DocumentView/HashLinkButton.tsx | 7 +- .../DocumentView/Tabs/DynamicTabs.tsx | 478 +++++++++++++----- .../gitbook/src/components/hooks/useHash.tsx | 3 +- packages/gitbook/src/components/utils/link.ts | 5 + 4 files changed, 348 insertions(+), 145 deletions(-) diff --git a/packages/gitbook/src/components/DocumentView/HashLinkButton.tsx b/packages/gitbook/src/components/DocumentView/HashLinkButton.tsx index 66718ecef2..8b4159d953 100644 --- a/packages/gitbook/src/components/DocumentView/HashLinkButton.tsx +++ b/packages/gitbook/src/components/DocumentView/HashLinkButton.tsx @@ -1,6 +1,7 @@ import { type ClassValue, tcls } from '@/lib/tailwind'; import type { DocumentBlockHeading, DocumentBlockTabs } from '@gitbook/api'; import { Icon } from '@gitbook/icons'; +import { Link } from '../primitives'; import { getBlockTextStyle } from './spacing'; /** @@ -35,10 +36,10 @@ export function HashLinkButton(props: { className )} > - - + ); } diff --git a/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx b/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx index f97df5bc7a..93a3dd66a4 100644 --- a/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx +++ b/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx @@ -1,12 +1,24 @@ 'use client'; -import React, { useCallback, useMemo } from 'react'; - -import { useHash, useIsMounted } from '@/components/hooks'; +import React, { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type ComponentPropsWithRef, +} from 'react'; + +import { NavigationStatusContext, useHash, useIsMounted } from '@/components/hooks'; +import { DropdownMenu, DropdownMenuItem } from '@/components/primitives'; +import { useLanguage } from '@/intl/client'; +import { tString } from '@/intl/translate'; import { getLocalStorageItem, setLocalStorageItem } from '@/lib/browser'; import { type ClassValue, tcls } from '@/lib/tailwind'; import type { DocumentBlockTabs } from '@gitbook/api'; -import { HashLinkButton, hashLinkButtonWrapperStyles } from '../HashLinkButton'; +import { Icon } from '@gitbook/icons'; +import { useRouter } from 'next/navigation'; interface TabsState { activeIds: { @@ -73,7 +85,9 @@ export function DynamicTabs( block: DocumentBlockTabs; } ) { - const { id, block, tabs, tabsBody, style } = props; + const { id, tabs, tabsBody, style } = props; + const router = useRouter(); + const { onNavigationClick } = React.useContext(NavigationStatusContext); const hash = useHash(); const [tabsState, setTabsState] = useTabsState(); @@ -96,159 +110,62 @@ export function DynamicTabs( * - mark this specific ID as selected * - store the ID to auto-select other tabs with the same title */ - const onSelectTab = React.useCallback( - (tab: TabsItem) => { - setTabsState((prev) => ({ - activeIds: { - ...prev.activeIds, - [id]: tab.id, - }, - activeTitles: tab.title - ? prev.activeTitles - .filter((t) => t !== tab.title) - .concat([tab.title]) - .slice(-TITLES_MAX) - : prev.activeTitles, - })); + const selectTab = useCallback( + (tabId: string) => { + const tab = tabs.find((tab) => tab.id === tabId); + + if (!tab) { + return; + } + + const href = `#${tab.id}`; + if (window.location.hash !== href) { + router.replace(href); + onNavigationClick(href); + } + + setTabsState((prev) => { + if (prev.activeIds[id] === tab.id) { + return prev; + } + return { + activeIds: { + ...prev.activeIds, + [id]: tab.id, + }, + activeTitles: tab.title + ? prev.activeTitles + .filter((t) => t !== tab.title) + .concat([tab.title]) + .slice(-TITLES_MAX) + : prev.activeTitles, + }; + }); }, - [id, setTabsState] + [onNavigationClick, router, setTabsState, tabs, id] ); /** * When the hash changes, we try to select the tab containing the targetted element. */ React.useEffect(() => { - if (!hash) { - return; - } - - const activeElement = document.getElementById(hash); - if (!activeElement) { - return; - } - - const tabAncestor = activeElement.closest('[role="tabpanel"]'); - if (!tabAncestor) { - return; + if (hash) { + selectTab(hash); } - - const tab = tabs.find((tab) => getTabPanelId(tab.id) === tabAncestor.id); - if (!tab) { - return; - } - - onSelectTab(tab); - }, [hash, tabs, onSelectTab]); + }, [selectTab, hash]); return (
-
- {tabs.map((tab) => ( -
- - - -
- ))} -
+ {tabs.map((tab, index) => (
void; +}) { + const { tabs, activeTabId, onSelect } = props; + const { containerRef, itemRef, overflowing, isMeasuring } = useListOverflow(); + const overflowingTabs = useMemo( + () => + Array.from(overflowing, (id) => { + const tabId = getTabIdFromButtonId(id); + return tabs.find((tab) => tab.id === tabId); + }).filter((x) => x !== undefined), + [overflowing, tabs] + ); + return ( +
+ {isMeasuring ? ( + + ) : null} + {tabs.map((tab) => { + if (overflowing.has(getTabButtonId(tab.id)) && !isMeasuring) { + return null; + } + return ( + + ); + })} + {overflowingTabs.length > 0 && !isMeasuring ? ( + + ) : null} +
+ ); +} + +function TabsDropdownMenu(props: { + tabs: TabsItem[]; + activeTabId: string | null; + onSelect: (tabId: string) => void; +}) { + const { tabs, onSelect, activeTabId } = props; + const language = useLanguage(); + return ( + tab.id === activeTabId)} + aria-label={tString(language, 'more')} + className="shrink-0" + > + + + } + > + {tabs.map((tab) => { + return ( + onSelect(tab.id)} + active={tab.id === activeTabId} + > + {tab.title} + + ); + })} + + ); +} + +interface OverflowState { + /** + * Ref for the container element. + */ + containerRef: React.RefObject; + /** + * Ref callback for each item in the list. + */ + itemRef: (element: HTMLElement | null) => void; + /** + * Set of IDs that are currently overflowing. + */ + overflowing: Set; + /** + * Indicates if we are currently measuring the list. + */ + isMeasuring: boolean; +} + +/** + * Detects which items are overflowing in a horizontal list. + */ +function useListOverflow(): OverflowState { + const containerRef = useRef(null); + const [overflowing, setOverflowing] = useState>(new Set()); + const [isMeasuring, setIsMeasuring] = useState(false); + const itemRefs = useRef(new Map()); + const rafRef = useRef(0); + + const itemRef = useCallback((element: HTMLElement | null) => { + if (!element) { + return; + } + itemRefs.current.set(element.id, element); + return () => { + itemRefs.current.delete(element.id); + }; + }, []); + + // Measure on mount and when container size changes + useEffect(() => { + if (!containerRef.current) { + return; + } + + setIsMeasuring(true); + + const ro = new ResizeObserver(() => { + cancelAnimationFrame(rafRef.current); + rafRef.current = requestAnimationFrame(() => { + setIsMeasuring(true); + }); + }); + + ro.observe(containerRef.current); + + return () => { + ro.disconnect(); + cancelAnimationFrame(rafRef.current); + }; + }, []); + + // Measure which items are overflowing + useLayoutEffect(() => { + if (!containerRef.current || !isMeasuring) { + return; + } + + const containerRect = containerRef.current.getBoundingClientRect(); + const newOverflowing = new Set(); + + itemRefs.current.forEach((el, id) => { + const elRect = el.getBoundingClientRect(); + if (elRect.right > containerRect.right + 1) { + newOverflowing.add(id); + } + }); + + setOverflowing((previous) => { + if (previous.size !== newOverflowing.size) { + return newOverflowing; + } + for (const id of previous) { + if (!newOverflowing.has(id)) { + return newOverflowing; + } + } + return previous; + }); + setIsMeasuring(false); + }, [isMeasuring]); + + return { containerRef, itemRef, overflowing, isMeasuring }; +} + +function TabItem(props: { + ref: React.Ref; + isActive: boolean; + tab: TabsItem; + onSelect: (tabId: string) => void; +}) { + const { ref, tab, isActive, onSelect } = props; + return ( + onSelect(tab.id)} + > + {tab.title} + + ); +} + +function TabButton( + props: ComponentPropsWithRef<'button'> & { + isActive?: boolean; + } +) { + const { isActive, ...rest } = props; + return ( +
+
+ ); +} + /** * Get the ID for a tab button. */ @@ -271,6 +459,16 @@ function getTabButtonId(tabId: string) { return `tab-${tabId}`; } +/** + * Get the ID of a tab from a button ID. + */ +function getTabIdFromButtonId(buttonId: string) { + if (buttonId.startsWith('tab-')) { + return buttonId.slice(4); + } + return buttonId; +} + /** * Get the ID for a tab panel. * We use the ID of the tab itself as links can be pointing to this ID. diff --git a/packages/gitbook/src/components/hooks/useHash.tsx b/packages/gitbook/src/components/hooks/useHash.tsx index 0468461e99..aff0358e6a 100644 --- a/packages/gitbook/src/components/hooks/useHash.tsx +++ b/packages/gitbook/src/components/hooks/useHash.tsx @@ -61,7 +61,7 @@ export const NavigationStatusProvider: React.FC = ({ ch const onNavigationClick = React.useCallback((href: string) => { // We need to skip it for search like params (i.e. ?ask= or ?q=) because they don't really trigger a navigation // Search may trigger a navigation whenn clicking on the ask ai for example, this is not something we want to track here - if (href.startsWith('?') || href.startsWith('#')) { + if (href.startsWith('?')) { return; } const url = new URL( @@ -102,7 +102,6 @@ export const NavigationStatusProvider: React.FC = ({ ch */ export function useHash() { const { hash } = React.useContext(NavigationStatusContext); - return hash; } diff --git a/packages/gitbook/src/components/utils/link.ts b/packages/gitbook/src/components/utils/link.ts index f20c22d3d1..8b0ef5c2eb 100644 --- a/packages/gitbook/src/components/utils/link.ts +++ b/packages/gitbook/src/components/utils/link.ts @@ -2,6 +2,11 @@ * Check if a link is external, compared to an origin. */ export function isExternalLink(href: string, origin: string | null = null) { + // Anchor links are not external + if (href.startsWith('#')) { + return false; + } + if (!URL.canParse) { // If URL.canParse is not available, we quickly check if it looks like a URL return href.startsWith('http'); From 3c03cc80b3171d77a6232e59cd12bfce5b313312 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Berg=C3=A9?= Date: Thu, 16 Oct 2025 22:59:39 +0200 Subject: [PATCH 2/6] Fix types --- .../gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx b/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx index 93a3dd66a4..2f8c43cba6 100644 --- a/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx +++ b/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx @@ -389,7 +389,7 @@ function TabItem(props: { } function TabButton( - props: ComponentPropsWithRef<'button'> & { + props: Omit, 'type'> & { isActive?: boolean; } ) { From 006f4fcb66c3d18f49ff5e0c2876b21f9ab1178c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Berg=C3=A9?= Date: Fri, 17 Oct 2025 09:08:19 +0200 Subject: [PATCH 3/6] Optimize perf, refactor code --- .../DocumentView/Tabs/DynamicTabs.tsx | 224 ++++++------------ .../src/components/DocumentView/Tabs/Tabs.tsx | 65 +++-- .../gitbook/src/components/hooks/index.ts | 1 + .../src/components/hooks/useListOverflow.tsx | 100 ++++++++ 4 files changed, 202 insertions(+), 188 deletions(-) create mode 100644 packages/gitbook/src/components/hooks/useListOverflow.tsx diff --git a/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx b/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx index 2f8c43cba6..a41edac427 100644 --- a/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx +++ b/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx @@ -1,22 +1,18 @@ 'use client'; -import React, { - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, - type ComponentPropsWithRef, -} from 'react'; - -import { NavigationStatusContext, useHash, useIsMounted } from '@/components/hooks'; +import React, { memo, useCallback, useMemo, type ComponentPropsWithRef } from 'react'; + +import { + NavigationStatusContext, + useHash, + useIsMounted, + useListOverflow, +} from '@/components/hooks'; import { DropdownMenu, DropdownMenuItem } from '@/components/primitives'; import { useLanguage } from '@/intl/client'; import { tString } from '@/intl/translate'; import { getLocalStorageItem, setLocalStorageItem } from '@/lib/browser'; -import { type ClassValue, tcls } from '@/lib/tailwind'; -import type { DocumentBlockTabs } from '@gitbook/api'; +import { tcls } from '@/lib/tailwind'; import { Icon } from '@gitbook/icons'; import { useRouter } from 'next/navigation'; @@ -58,16 +54,9 @@ const TITLES_MAX = 5; export interface TabsItem { id: string; title: string; + body: React.ReactNode; } -type SelectorMapper = { - [Property in keyof Type]: Type[Property]; -}; -type TabsInput = { - id: string; - tabs: SelectorMapper[]; -}; - interface TabsState { activeIds: { [tabsBlockId: string]: string; @@ -78,14 +67,12 @@ interface TabsState { /** * Client side component for the tabs, taking care of interactions. */ -export function DynamicTabs( - props: TabsInput & { - tabsBody: React.ReactNode[]; - style: ClassValue; - block: DocumentBlockTabs; - } -) { - const { id, tabs, tabsBody, style } = props; +export function DynamicTabs(props: { + id: string; + tabs: TabsItem[]; + className?: string; +}) { + const { id, tabs, className } = props; const router = useRouter(); const { onNavigationClick } = React.useContext(NavigationStatusContext); @@ -105,11 +92,10 @@ export function DynamicTabs( const mounted = useIsMounted(); const active = mounted ? activeState : tabs[0]; - /** - * When clicking to select a tab, we: - * - mark this specific ID as selected - * - store the ID to auto-select other tabs with the same title - */ + // When clicking to select a tab, we: + // - update the URL hash + // - mark this specific ID as selected + // - store the ID to auto-select other tabs with the same title const selectTab = useCallback( (tabId: string) => { const tab = tabs.find((tab) => tab.id === tabId); @@ -145,14 +131,30 @@ export function DynamicTabs( [onNavigationClick, router, setTabsState, tabs, id] ); - /** - * When the hash changes, we try to select the tab containing the targetted element. - */ + // When the hash changes, we try to select the tab containing the targetted element. React.useEffect(() => { if (hash) { - selectTab(hash); + // First check if the hash matches a tab ID. + const hashIsTab = tabs.some((tab) => tab.id === hash); + if (hashIsTab) { + selectTab(hash); + return; + } + + // Then check if the hash matches an element inside a tab. + const activeElement = document.getElementById(hash); + if (!activeElement) { + return; + } + + const tabPanel = activeElement.closest('[role="tabpanel"]'); + if (!tabPanel) { + return; + } + + selectTab(tabPanel.id); } - }, [selectTab, hash]); + }, [selectTab, tabs, hash]); return (
- {tabs.map((tab, index) => ( + {tabs.map((tab) => (
- {tabsBody[index]} + {tab.body}
))}
); } -function TabItemList(props: { +const TabItemList = memo(function TabItemList(props: { tabs: TabsItem[]; activeTabId: string | null; - onSelect: (id: string) => void; + onSelect: (tabId: string) => void; }) { const { tabs, activeTabId, onSelect } = props; const { containerRef, itemRef, overflowing, isMeasuring } = useListOverflow(); @@ -210,10 +212,12 @@ function TabItemList(props: { '[&:has(button.active-tab:last-of-type):after]:rounded-bl-md' )} > + {/* When we measure, we add the menu at start to be sure everything's fit. */} {isMeasuring ? ( ) : null} {tabs.map((tab) => { + // Hide overflowing tabs when not measuring. if (overflowing.has(getTabButtonId(tab.id)) && !isMeasuring) { return null; } @@ -227,6 +231,7 @@ function TabItemList(props: { /> ); })} + {/* Dropdown for overflowing tabs */} {overflowingTabs.length > 0 && !isMeasuring ? ( ); -} +}); function TabsDropdownMenu(props: { tabs: TabsItem[]; @@ -272,102 +277,10 @@ function TabsDropdownMenu(props: { ); } -interface OverflowState { - /** - * Ref for the container element. - */ - containerRef: React.RefObject; - /** - * Ref callback for each item in the list. - */ - itemRef: (element: HTMLElement | null) => void; - /** - * Set of IDs that are currently overflowing. - */ - overflowing: Set; - /** - * Indicates if we are currently measuring the list. - */ - isMeasuring: boolean; -} - /** - * Detects which items are overflowing in a horizontal list. + * Tab item that accepts a `tab` prop. */ -function useListOverflow(): OverflowState { - const containerRef = useRef(null); - const [overflowing, setOverflowing] = useState>(new Set()); - const [isMeasuring, setIsMeasuring] = useState(false); - const itemRefs = useRef(new Map()); - const rafRef = useRef(0); - - const itemRef = useCallback((element: HTMLElement | null) => { - if (!element) { - return; - } - itemRefs.current.set(element.id, element); - return () => { - itemRefs.current.delete(element.id); - }; - }, []); - - // Measure on mount and when container size changes - useEffect(() => { - if (!containerRef.current) { - return; - } - - setIsMeasuring(true); - - const ro = new ResizeObserver(() => { - cancelAnimationFrame(rafRef.current); - rafRef.current = requestAnimationFrame(() => { - setIsMeasuring(true); - }); - }); - - ro.observe(containerRef.current); - - return () => { - ro.disconnect(); - cancelAnimationFrame(rafRef.current); - }; - }, []); - - // Measure which items are overflowing - useLayoutEffect(() => { - if (!containerRef.current || !isMeasuring) { - return; - } - - const containerRect = containerRef.current.getBoundingClientRect(); - const newOverflowing = new Set(); - - itemRefs.current.forEach((el, id) => { - const elRect = el.getBoundingClientRect(); - if (elRect.right > containerRect.right + 1) { - newOverflowing.add(id); - } - }); - - setOverflowing((previous) => { - if (previous.size !== newOverflowing.size) { - return newOverflowing; - } - for (const id of previous) { - if (!newOverflowing.has(id)) { - return newOverflowing; - } - } - return previous; - }); - setIsMeasuring(false); - }, [isMeasuring]); - - return { containerRef, itemRef, overflowing, isMeasuring }; -} - -function TabItem(props: { +const TabItem = memo(function TabItem(props: { ref: React.Ref; isActive: boolean; tab: TabsItem; @@ -379,15 +292,18 @@ function TabItem(props: { ref={ref} role="tab" aria-selected={isActive} - aria-controls={getTabPanelId(tab.id)} + aria-controls={tab.id} id={getTabButtonId(tab.id)} onClick={() => onSelect(tab.id)} > {tab.title} ); -} +}); +/** + * Generic tab button component, low-level. + */ function TabButton( props: Omit, 'type'> & { isActive?: boolean; @@ -469,18 +385,16 @@ function getTabIdFromButtonId(buttonId: string) { return buttonId; } -/** - * Get the ID for a tab panel. - * We use the ID of the tab itself as links can be pointing to this ID. - */ -function getTabPanelId(tabId: string) { - return tabId; -} - /** * Get explicitly selected tab in a set of tabs. */ -function getTabBySelection(input: TabsInput, state: TabsState): TabsItem | null { +function getTabBySelection( + input: { + id: string; + tabs: TabsItem[]; + }, + state: TabsState +): TabsItem | null { const activeId = state.activeIds[input.id]; return activeId ? (input.tabs.find((child) => child.id === activeId) ?? null) : null; } @@ -488,7 +402,13 @@ function getTabBySelection(input: TabsInput, state: TabsState): TabsItem | null /** * Get the best selected tab in a set of tabs by taking only title into account. */ -function getTabByTitle(input: TabsInput, state: TabsState): TabsItem | null { +function getTabByTitle( + input: { + id: string; + tabs: TabsItem[]; + }, + state: TabsState +): TabsItem | null { return ( input.tabs .map((item) => { diff --git a/packages/gitbook/src/components/DocumentView/Tabs/Tabs.tsx b/packages/gitbook/src/components/DocumentView/Tabs/Tabs.tsx index d1ab244852..bb9be4b086 100644 --- a/packages/gitbook/src/components/DocumentView/Tabs/Tabs.tsx +++ b/packages/gitbook/src/components/DocumentView/Tabs/Tabs.tsx @@ -9,47 +9,40 @@ import { DynamicTabs, type TabsItem } from './DynamicTabs'; export function Tabs(props: BlockProps) { const { block, ancestorBlocks, document, style, context } = props; - const tabs: TabsItem[] = []; - const tabsBody: React.ReactNode[] = []; + if (!block.key) { + throw new Error('Tabs block is missing a key'); + } - block.nodes.forEach((tab, index) => { - tabs.push({ - id: tab.meta?.id ?? tab.key!, - title: tab.data.title ?? '', - }); + const id = block.key; - tabsBody.push( - - ); + const tabs: TabsItem[] = block.nodes.map((tab) => { + if (!tab.key) { + throw new Error('Tab block is missing a key'); + } + + return { + id: tab.meta?.id ?? tab.key, + title: tab.data.title ?? '', + body: ( + + ), + }; }); + // When printing, we display the tab, one after the other if (context.mode === 'print') { - // When printing, we display the tab, one after the other - return ( - <> - {tabs.map((tab, index) => ( - - ))} - - ); + return tabs.map((tab) => { + ; + }); } - return ( - - ); + return ; } diff --git a/packages/gitbook/src/components/hooks/index.ts b/packages/gitbook/src/components/hooks/index.ts index 3c7ef18d80..8bbafa1423 100644 --- a/packages/gitbook/src/components/hooks/index.ts +++ b/packages/gitbook/src/components/hooks/index.ts @@ -7,3 +7,4 @@ export * from './useCurrentPagePath'; export * from './useCurrentContent'; export * from './useCurrentPage'; export * from './useNow'; +export * from './useListOverflow'; diff --git a/packages/gitbook/src/components/hooks/useListOverflow.tsx b/packages/gitbook/src/components/hooks/useListOverflow.tsx new file mode 100644 index 0000000000..e43513de07 --- /dev/null +++ b/packages/gitbook/src/components/hooks/useListOverflow.tsx @@ -0,0 +1,100 @@ +'use client'; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; + +interface OverflowState { + /** + * Ref for the container element. + */ + containerRef: React.RefObject; + /** + * Ref callback for each item in the list. + */ + itemRef: (element: HTMLElement | null) => void; + /** + * Set of IDs that are currently overflowing. + */ + overflowing: Set; + /** + * Indicates if we are currently measuring the list. + */ + isMeasuring: boolean; +} + +/** + * Detects which items are overflowing in a horizontal list. + * The items must have unique IDs set on their elements. + * + * In the measuring phase indicated by `isMeasuring`, all items must be rendered. + */ +export function useListOverflow(): OverflowState { + const containerRef = useRef(null); + const [overflowing, setOverflowing] = useState>(new Set()); + const [isMeasuring, setIsMeasuring] = useState(false); + const itemRefs = useRef(new Map()); + const rafRef = useRef(0); + + const itemRef = useCallback((element: HTMLElement | null) => { + if (!element) { + return; + } + itemRefs.current.set(element.id, element); + return () => { + itemRefs.current.delete(element.id); + }; + }, []); + + // Measure on mount and when container size changes + useEffect(() => { + if (!containerRef.current) { + return; + } + + setIsMeasuring(true); + + const ro = new ResizeObserver(() => { + cancelAnimationFrame(rafRef.current); + rafRef.current = requestAnimationFrame(() => { + setIsMeasuring(true); + }); + }); + + ro.observe(containerRef.current); + + return () => { + ro.disconnect(); + cancelAnimationFrame(rafRef.current); + }; + }, []); + + // Measure which items are overflowing + useLayoutEffect(() => { + if (!containerRef.current || !isMeasuring) { + return; + } + + const containerRect = containerRef.current.getBoundingClientRect(); + const newOverflowing = new Set(); + + itemRefs.current.forEach((el, id) => { + const elRect = el.getBoundingClientRect(); + if (elRect.right > containerRect.right + 1) { + newOverflowing.add(id); + } + }); + + setOverflowing((previous) => { + if (previous.size !== newOverflowing.size) { + return newOverflowing; + } + for (const id of previous) { + if (!newOverflowing.has(id)) { + return newOverflowing; + } + } + return previous; + }); + setIsMeasuring(false); + }, [isMeasuring]); + + return { containerRef, itemRef, overflowing, isMeasuring }; +} From 255d56aa4b01c747de62558ba6c3df810b592ce6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Berg=C3=A9?= Date: Fri, 17 Oct 2025 09:32:47 +0200 Subject: [PATCH 4/6] Fix expandable alignment --- .../src/components/DocumentView/Expandable/Expandable.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/gitbook/src/components/DocumentView/Expandable/Expandable.tsx b/packages/gitbook/src/components/DocumentView/Expandable/Expandable.tsx index 08daab052e..8a3de8f413 100644 --- a/packages/gitbook/src/components/DocumentView/Expandable/Expandable.tsx +++ b/packages/gitbook/src/components/DocumentView/Expandable/Expandable.tsx @@ -51,7 +51,7 @@ export function Expandable(props: BlockProps) { className={tcls( 'inline-block', 'size-3', - 'mr-2', + 'mr-3', 'mb-1', 'transition-transform', 'shrink-0', @@ -96,7 +96,7 @@ export function Expandable(props: BlockProps) { document={document} ancestorBlocks={[...ancestorBlocks, block]} context={context} - style={['px-10', 'pb-5', 'space-y-4']} + style="space-y-4 px-10 pb-5" /> ); From 10874283d5506346ed6f2512aaf098311c656304 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Berg=C3=A9?= Date: Fri, 17 Oct 2025 09:33:04 +0200 Subject: [PATCH 5/6] Improve heading support in tabs --- .../DocumentView/HashLinkButton.tsx | 2 ++ .../DocumentView/Tabs/DynamicTabs.tsx | 31 +++++++++++++------ .../src/components/DocumentView/Tabs/Tabs.tsx | 2 +- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/packages/gitbook/src/components/DocumentView/HashLinkButton.tsx b/packages/gitbook/src/components/DocumentView/HashLinkButton.tsx index 8b4159d953..3f6f42f3d0 100644 --- a/packages/gitbook/src/components/DocumentView/HashLinkButton.tsx +++ b/packages/gitbook/src/components/DocumentView/HashLinkButton.tsx @@ -29,6 +29,8 @@ export function HashLinkButton(props: { 'h-[1em]', 'border-0', 'opacity-0', + 'site-background', + 'rounded', 'group-hover/hash:opacity-[0]', 'group-focus/hash:opacity-[0]', 'md:group-hover/hash:opacity-[1]', diff --git a/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx b/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx index a41edac427..2315c1d0bf 100644 --- a/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx +++ b/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx @@ -163,26 +163,34 @@ export function DynamicTabs(props: { 'straight-corners:rounded-xs', 'ring-1 ring-tint-subtle ring-inset', 'flex flex-col', - 'overflow-hidden', className )} > {tabs.map((tab) => ( -
- {tab.body} -
+ ))}
); } +const TabPanel = memo(function TabPanel(props: { + tab: TabsItem; + isActive: boolean; +}) { + const { tab, isActive } = props; + return ( +
+ {tab.body} +
+ ); +}); + const TabItemList = memo(function TabItemList(props: { tabs: TabsItem[]; activeTabId: string | null; @@ -204,6 +212,9 @@ const TabItemList = memo(function TabItemList(props: { role="tablist" className={tcls( 'group/tabs', + 'overflow-hidden', + 'rounded-t-lg', + 'straight-corners:rounded-t-xs', 'inline-flex', 'self-stretch', 'after:flex-1', diff --git a/packages/gitbook/src/components/DocumentView/Tabs/Tabs.tsx b/packages/gitbook/src/components/DocumentView/Tabs/Tabs.tsx index bb9be4b086..a7b85e23be 100644 --- a/packages/gitbook/src/components/DocumentView/Tabs/Tabs.tsx +++ b/packages/gitbook/src/components/DocumentView/Tabs/Tabs.tsx @@ -40,7 +40,7 @@ export function Tabs(props: BlockProps) { // When printing, we display the tab, one after the other if (context.mode === 'print') { return tabs.map((tab) => { - ; + return ; }); } From bcdf87a874111aee1cb67466487477d7e0faee2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Berg=C3=A9?= Date: Fri, 17 Oct 2025 11:49:27 +0200 Subject: [PATCH 6/6] Fix issue on mobile --- .../gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx b/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx index 2315c1d0bf..ad0dd304f3 100644 --- a/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx +++ b/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx @@ -162,7 +162,7 @@ export function DynamicTabs(props: { 'rounded-lg', 'straight-corners:rounded-xs', 'ring-1 ring-tint-subtle ring-inset', - 'flex flex-col', + 'flex min-w-0 flex-col', className )} >