From 66ce3e3c62bda281cdc19beae82520094bb802ba Mon Sep 17 00:00:00 2001 From: Zeno Kapitein Date: Mon, 15 Sep 2025 20:06:28 +0200 Subject: [PATCH 1/6] Refactor site section tabs --- .changeset/spicy-boats-change.md | 5 + .../gitbook/src/components/Header/Header.tsx | 46 +-- .../src/components/RootLayout/globals.css | 1 + .../SiteSections/SiteSectionTabs.tsx | 291 ++++++++---------- .../src/components/primitives/Button.tsx | 8 +- 5 files changed, 155 insertions(+), 196 deletions(-) create mode 100644 .changeset/spicy-boats-change.md diff --git a/.changeset/spicy-boats-change.md b/.changeset/spicy-boats-change.md new file mode 100644 index 0000000000..42cb7ea6e3 --- /dev/null +++ b/.changeset/spicy-boats-change.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Refactor section tabs diff --git a/packages/gitbook/src/components/Header/Header.tsx b/packages/gitbook/src/components/Header/Header.tsx index e3be0abba8..fbcba97090 100644 --- a/packages/gitbook/src/components/Header/Header.tsx +++ b/packages/gitbook/src/components/Header/Header.tsx @@ -163,42 +163,18 @@ export function Header(props: { {sections ? ( -
-
-
- {sections.list.some((s) => s.object === 'site-section-group') || // If there's even a single group, show the tabs - sections.list.length > 1 ? ( // Otherwise, show the tabs if there's more than one section - + + {withVariants === 'translations' ? ( +
+ - ) : null} - {withVariants === 'translations' ? ( -
- -
- ) : null} -
-
+
+ ) : null} +
) : null} diff --git a/packages/gitbook/src/components/RootLayout/globals.css b/packages/gitbook/src/components/RootLayout/globals.css index 9c86654aab..b37f224e9b 100644 --- a/packages/gitbook/src/components/RootLayout/globals.css +++ b/packages/gitbook/src/components/RootLayout/globals.css @@ -217,6 +217,7 @@ ::-webkit-scrollbar { background: transparent; max-width: 8px; + max-height: 6px; } ::-webkit-scrollbar-thumb { diff --git a/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx b/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx index cbde062604..090f733ce6 100644 --- a/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx +++ b/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx @@ -1,142 +1,157 @@ 'use client'; -import { Icon, type IconName } from '@gitbook/icons'; +import type { IconName } from '@gitbook/icons'; import * as NavigationMenu from '@radix-ui/react-navigation-menu'; import React from 'react'; -import { Link } from '@/components/primitives'; +import { Button, DropdownChevron, Link } from '@/components/primitives'; import { tcls } from '@/lib/tailwind'; +import { useIsMobile } from '../hooks/useIsMobile'; +import { CONTAINER_STYLE } from '../layout'; import { SectionIcon } from './SectionIcon'; import type { ClientSiteSection, ClientSiteSections } from './encodeClientSiteSections'; -const VIEWPORT_ITEM_WIDTH = 240; /* width of the tile (w-60) */ +const SCREEN_OFFSET = 16; // 1rem +const VIEWPORT_PADDING = 8; // 0.5rem const MIN_ITEMS_FOR_COLS = 4; /* number of items to switch to 2 columns */ /** * A set of navigational links representing site sections for multi-section sites */ -export function SiteSectionTabs(props: { sections: ClientSiteSections }) { +export function SiteSectionTabs(props: { + sections: ClientSiteSections; + className?: string; + children?: React.ReactNode; +}) { const { sections: { list: sectionsAndGroups, current: currentSection }, + className, + children, } = props; - const [value, setValue] = React.useState(); + + const currentTriggerRef = React.useRef(null); const [offset, setOffset] = React.useState(null); - const menuContainerRef = React.useRef(null); + const [value, setValue] = React.useState(undefined); - const onNodeUpdate = (trigger: HTMLButtonElement | null, itemValue: string, size = 0) => { - const padding = 16; - const margin = -12; // Offsetting the menu container's negative margin - const windowWidth = document.documentElement.clientWidth; - const windowBuffer = 16; // constrain to within the window with some buffer on the left and right we don't want the menu to enter - const viewportWidth = - size < MIN_ITEMS_FOR_COLS - ? VIEWPORT_ITEM_WIDTH + padding - : VIEWPORT_ITEM_WIDTH * 2 + padding; - const minOffset = 0 - (menuContainerRef.current?.offsetLeft ?? 0) + margin; - const maxOffset = minOffset + windowWidth - viewportWidth; + const isMobile = useIsMobile(768); - if (windowWidth < 768) { - // if the screen is small don't offset the menu - setOffset(minOffset + windowBuffer); - } else if (trigger && value === itemValue) { - const position = minOffset + trigger?.getBoundingClientRect().left; - setOffset( - Math.min(maxOffset - windowBuffer, Math.max(minOffset + windowBuffer, position)) - ); - } else if (!value) { - setOffset(null); + React.useEffect(() => { + const trigger = currentTriggerRef.current; + if (!trigger) { + return; } - }; + + const triggerWidth = trigger.getBoundingClientRect().width; + const triggerLeft = trigger.getBoundingClientRect().left; + setOffset(triggerLeft + triggerWidth / 2); + }); return sectionsAndGroups.length > 0 ? (
- - {sectionsAndGroups.map((sectionOrGroup) => { - const { id, title, icon } = sectionOrGroup; - const isGroup = sectionOrGroup.object === 'site-section-group'; - const isActiveGroup = - isGroup && - Boolean( - sectionOrGroup.sections.find((s) => s.id === currentSection.id) - ); - const isActive = isActiveGroup || id === currentSection.id; - return ( - - {isGroup ? ( - sectionOrGroup.sections.length > 0 ? ( - <> - - onNodeUpdate( - node, - id, - sectionOrGroup.sections.length - ) - } - asChild - onClick={(e) => { - if (value) { - e.preventDefault(); - e.stopPropagation(); - } - }} - > - - - - - - - ) : null - ) : ( - - - - )} - - ); - })} - + -
- - + {sectionsAndGroups.map((sectionOrGroup) => { + const { id, title, icon } = sectionOrGroup; + const isGroup = sectionOrGroup.object === 'site-section-group'; + const isActiveGroup = + isGroup && + Boolean( + sectionOrGroup.sections.find((s) => s.id === currentSection.id) + ); + const isActive = isActiveGroup || id === currentSection.id; + return ( + + {isGroup ? ( + sectionOrGroup.sections.length > 0 ? ( + <> + { + // Prevent clicking the trigger from closing when the viewport is open + if (value === id) { + e.preventDefault(); + e.stopPropagation(); + } + }} + > + + + + + + + ) : null + ) : ( + + + + )} + + ); + })} + +
+ + {children}
+
@@ -145,76 +160,34 @@ export function SiteSectionTabs(props: { sections: ClientSiteSections }) { } /** - * A tab representing a section + * 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 }, ref: React.Ref ) { const { isActive, title, icon, url, ...rest } = props; return ( - : null} + label={title} + trailing={!url ? : null} + active={isActive} className={tcls( - 'group relative my-2 flex select-none items-center justify-between circular-corners:rounded-full rounded-corners:rounded-xs px-3 py-1', + 'group/dropdown relative my-2 overflow-visible px-3 py-1', isActive - ? 'text-primary-subtle' - : 'text-tint hover:bg-tint-hover hover:text-tint-strong' + ? 'after:contents-[] after:-bottom-2 bg-transparent text-primary-subtle after:absolute after:inset-x-3 after:h-0.5 after:bg-primary-9' + : '' )} href={url} - > - - {icon ? : null} - {title} - - {isActive ? : null} - - ); -}); - -/** - * A tab representing a section group - */ -const SectionGroupTab = React.forwardRef(function SectionGroupTab( - props: { isActive: boolean; title: string; icon?: IconName }, - ref: React.Ref -) { - const { isActive, title, icon, ...rest } = props; - return ( - + /> ); }); -/** - * Horizontal line indicating the active tab - */ -function ActiveTabIndicator() { - return ( - - ); -} - /** * A list of section tiles grouped in the dropdown for a section group */ @@ -226,7 +199,7 @@ function SectionGroupTileList(props: { return (
    diff --git a/packages/gitbook/src/components/primitives/Button.tsx b/packages/gitbook/src/components/primitives/Button.tsx index 24af6ff9ce..8900c3f315 100644 --- a/packages/gitbook/src/components/primitives/Button.tsx +++ b/packages/gitbook/src/components/primitives/Button.tsx @@ -41,10 +41,14 @@ export const variantClasses = { 'text-tint', 'border-0', 'shadow-none!', + 'translate-y-0!', 'hover:bg-tint-hover', 'hover:text-tint-strong', + 'focus-visible:bg-tint-hover', + 'focus-visible:text-tint-strong', + 'data-[state=open]:bg-tint-hover', + 'data-[state=open]:text-tint-strong', 'contrast-more:bg-tint-subtle', - 'hover:depth-subtle:translate-y-0', 'disabled:text-tint/8', 'disabled:bg-transparent', ], @@ -82,7 +86,7 @@ export const variantClasses = { const activeClasses = { primary: 'bg-primary-solid-hover', - blank: 'bg-primary-active disabled:bg-primary-active text-primary-strong font-medium hover:text-primary-strong disabled:text-primary-strong hover:bg-primary-active', + blank: 'bg-primary-active disabled:bg-primary-active text-primary-strong font-medium hover:text-primary-strong disabled:text-primary-strong hover:bg-primary-active focus-visible:bg-primary-active focus-visible:text-primary-strong data-[state=open]:bg-primary-active data-[state=open]:text-primary-strong', secondary: 'bg-tint-active disabled:bg-tint-active', header: 'bg-header-link/3', }; From bb29763eb8736986e3d0f8def276192062fe97f9 Mon Sep 17 00:00:00 2001 From: Zeno Kapitein Date: Tue, 16 Sep 2025 12:04:16 +0200 Subject: [PATCH 2/6] Simplify --- .../gitbook/src/components/Header/Header.tsx | 2 +- .../src/components/Header/SpacesDropdown.tsx | 4 +- .../SiteSections/SiteSectionTabs.tsx | 145 +++++++++--------- 3 files changed, 73 insertions(+), 78 deletions(-) diff --git a/packages/gitbook/src/components/Header/Header.tsx b/packages/gitbook/src/components/Header/Header.tsx index fbcba97090..9cfbcc113c 100644 --- a/packages/gitbook/src/components/Header/Header.tsx +++ b/packages/gitbook/src/components/Header/Header.tsx @@ -166,7 +166,7 @@ export function Header(props: {
    {withVariants === 'translations' ? ( -
    +
    diff --git a/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx b/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx index 090f733ce6..93d132d3ab 100644 --- a/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx +++ b/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx @@ -47,92 +47,86 @@ export function SiteSectionTabs(props: { return sectionsAndGroups.length > 0 ? (
    -
    - - {sectionsAndGroups.map((sectionOrGroup) => { - const { id, title, icon } = sectionOrGroup; - const isGroup = sectionOrGroup.object === 'site-section-group'; - const isActiveGroup = - isGroup && - Boolean( - sectionOrGroup.sections.find((s) => s.id === currentSection.id) - ); - const isActive = isActiveGroup || id === currentSection.id; - return ( - - {isGroup ? ( - sectionOrGroup.sections.length > 0 ? ( - <> - { - // Prevent clicking the trigger from closing when the viewport is open - if (value === id) { - e.preventDefault(); - e.stopPropagation(); - } - }} - > - - - - - - - ) : null - ) : ( - - - - )} - + {sectionsAndGroups.map((sectionOrGroup) => { + const { id, title, icon } = sectionOrGroup; + const isGroup = sectionOrGroup.object === 'site-section-group'; + const isActiveGroup = + isGroup && + Boolean( + sectionOrGroup.sections.find((s) => s.id === currentSection.id) ); - })} - -
    - - {children} + const isActive = isActiveGroup || id === currentSection.id; + return ( + + {isGroup ? ( + sectionOrGroup.sections.length > 0 ? ( + <> + { + // Prevent clicking the trigger from closing when the viewport is open + if (value === id) { + e.preventDefault(); + e.stopPropagation(); + } + }} + > + + + + + + + ) : null + ) : ( + + + + )} + + ); + })} +
    + {children} +
    ) { const { isActive, title, icon, url, ...rest } = props; + const isGroup = url === undefined; return (
    + ); +} diff --git a/packages/gitbook/src/intl/translations/de.ts b/packages/gitbook/src/intl/translations/de.ts index 7c5811ec1b..721637a490 100644 --- a/packages/gitbook/src/intl/translations/de.ts +++ b/packages/gitbook/src/intl/translations/de.ts @@ -120,4 +120,6 @@ export const de = { copy_mcp_url: 'MCP-Server-URL kopieren', press_to_confirm: 'Drücke ${1} zum Bestätigen', tool_call_skipped: 'Übersprungen "${1}"', + scroll_back: 'Zurück scrollen', + scroll_further: 'Weiter scrollen', }; diff --git a/packages/gitbook/src/intl/translations/en.ts b/packages/gitbook/src/intl/translations/en.ts index c06e5b92e1..346ff0f2cd 100644 --- a/packages/gitbook/src/intl/translations/en.ts +++ b/packages/gitbook/src/intl/translations/en.ts @@ -117,4 +117,6 @@ export const en = { copy_mcp_url: 'Copy the MCP Server URL', press_to_confirm: 'Press ${1} to confirm', tool_call_skipped: 'Skipped "${1}"', + scroll_back: 'Scroll back', + scroll_further: 'Scroll further', }; diff --git a/packages/gitbook/src/intl/translations/es.ts b/packages/gitbook/src/intl/translations/es.ts index 8f28f36656..606136f329 100644 --- a/packages/gitbook/src/intl/translations/es.ts +++ b/packages/gitbook/src/intl/translations/es.ts @@ -121,4 +121,6 @@ export const es: TranslationLanguage = { copy_mcp_url: 'Copiar URL del servidor MCP', press_to_confirm: 'Presiona ${1} para confirmar', tool_call_skipped: 'Omitido "${1}"', + scroll_back: 'Desplazar hacia atrás', + scroll_further: 'Desplazar más', }; diff --git a/packages/gitbook/src/intl/translations/fr.ts b/packages/gitbook/src/intl/translations/fr.ts index d99fddd7fe..7afa404488 100644 --- a/packages/gitbook/src/intl/translations/fr.ts +++ b/packages/gitbook/src/intl/translations/fr.ts @@ -116,4 +116,6 @@ export const fr = { copy_mcp_url: "Copier l'URL du serveur MCP", press_to_confirm: 'Appuyez sur ${1} pour confirmer', tool_call_skipped: 'Ignoré "${1}"', + scroll_back: "Défiler vers l'arrière", + scroll_further: 'Défiler plus loin', }; diff --git a/packages/gitbook/src/intl/translations/ja.ts b/packages/gitbook/src/intl/translations/ja.ts index f344796edf..15709f02c4 100644 --- a/packages/gitbook/src/intl/translations/ja.ts +++ b/packages/gitbook/src/intl/translations/ja.ts @@ -119,4 +119,6 @@ export const ja: TranslationLanguage = { copy_mcp_url: 'MCPサーバーのURLをコピー', press_to_confirm: '確認するには${1}を押してください', tool_call_skipped: '"${1}" をスキップしました', + scroll_back: '戻る', + scroll_further: '進む', }; diff --git a/packages/gitbook/src/intl/translations/nl.ts b/packages/gitbook/src/intl/translations/nl.ts index 87432b1249..042c5b9a37 100644 --- a/packages/gitbook/src/intl/translations/nl.ts +++ b/packages/gitbook/src/intl/translations/nl.ts @@ -119,4 +119,6 @@ export const nl: TranslationLanguage = { copy_mcp_url: 'Kopieer MCP-server URL', press_to_confirm: 'Druk op ${1} om te bevestigen', tool_call_skipped: '"${1}" overgeslagen', + scroll_back: 'Terug scrollen', + scroll_further: 'Verder scrollen', }; diff --git a/packages/gitbook/src/intl/translations/no.ts b/packages/gitbook/src/intl/translations/no.ts index 4d12613a04..8b1a5813e1 100644 --- a/packages/gitbook/src/intl/translations/no.ts +++ b/packages/gitbook/src/intl/translations/no.ts @@ -120,4 +120,6 @@ export const no: TranslationLanguage = { copy_mcp_url: 'Kopier MCP-server URL', press_to_confirm: 'Trykk ${1} for å bekrefte', tool_call_skipped: 'Hoppet over "${1}"', + scroll_back: 'Rull tilbake', + scroll_further: 'Rull videre', }; diff --git a/packages/gitbook/src/intl/translations/pt-br.ts b/packages/gitbook/src/intl/translations/pt-br.ts index eb2ddc15e5..50953e6275 100644 --- a/packages/gitbook/src/intl/translations/pt-br.ts +++ b/packages/gitbook/src/intl/translations/pt-br.ts @@ -119,4 +119,6 @@ export const pt_br = { copy_mcp_url: 'Copiar URL do servidor MCP', press_to_confirm: 'Pressione ${1} para confirmar', tool_call_skipped: 'Pulado "${1}"', + scroll_back: 'Rolar para trás', + scroll_further: 'Rolar para frente', }; diff --git a/packages/gitbook/src/intl/translations/ru.ts b/packages/gitbook/src/intl/translations/ru.ts index 4a1b9523ec..7771b09399 100644 --- a/packages/gitbook/src/intl/translations/ru.ts +++ b/packages/gitbook/src/intl/translations/ru.ts @@ -118,4 +118,6 @@ export const ru = { copy_mcp_url: 'Скопировать URL MCP-сервера', press_to_confirm: 'Нажмите ${1} для подтверждения', tool_call_skipped: 'Пропущен "${1}"', + scroll_back: 'Прокрутить назад', + scroll_further: 'Прокрутить дальше', }; diff --git a/packages/gitbook/src/intl/translations/zh.ts b/packages/gitbook/src/intl/translations/zh.ts index 57813af063..74baf6828f 100644 --- a/packages/gitbook/src/intl/translations/zh.ts +++ b/packages/gitbook/src/intl/translations/zh.ts @@ -116,4 +116,6 @@ export const zh: TranslationLanguage = { copy_mcp_url: '复制 MCP 服务器 URL', press_to_confirm: '按 ${1} 确认', tool_call_skipped: '已跳过 "${1}"', + scroll_back: '向后滚动', + scroll_further: '向前滚动', }; From 528674d9afdeff52019c228252924beda50be43f Mon Sep 17 00:00:00 2001 From: Zeno Kapitein Date: Wed, 17 Sep 2025 17:43:51 +0200 Subject: [PATCH 5/6] Review & add to site sections list --- .../SiteSections/SiteSectionList.tsx | 53 +++++++++---------- .../components/primitives/ScrollContainer.tsx | 19 ++++--- 2 files changed, 37 insertions(+), 35 deletions(-) diff --git a/packages/gitbook/src/components/SiteSections/SiteSectionList.tsx b/packages/gitbook/src/components/SiteSections/SiteSectionList.tsx index 6499b2a840..066625f423 100644 --- a/packages/gitbook/src/components/SiteSections/SiteSectionList.tsx +++ b/packages/gitbook/src/components/SiteSections/SiteSectionList.tsx @@ -5,10 +5,9 @@ import { motion } from 'framer-motion'; import React from 'react'; import { type ClassValue, tcls } from '@/lib/tailwind'; - -import { TOCScrollContainer, useScrollToActiveTOCItem } from '../TableOfContents/TOCScroller'; -import { useIsMounted, useToggleAnimation } from '../hooks'; +import { useToggleAnimation } from '../hooks'; import { Link } from '../primitives'; +import { ScrollContainer } from '../primitives/ScrollContainer'; import { SectionIcon } from './SectionIcon'; import type { ClientSiteSection, @@ -36,30 +35,34 @@ export function SiteSectionList(props: { sections: ClientSiteSections; className className )} > - - {sectionsAndGroups.map((item) => { - if (item.object === 'site-section-group') { +
    + {sectionsAndGroups.map((item) => { + if (item.object === 'site-section-group') { + return ( + + ); + } + return ( - ); - } - - return ( - - ); - })} - + })} +
    + ) ); @@ -72,17 +75,11 @@ export function SiteSectionListItem(props: { }) { const { section, isActive, className, ...otherProps } = props; - const isMounted = useIsMounted(); - React.useEffect(() => {}, [isMounted]); // This updates the useScrollToActiveTOCItem hook once we're mounted, so we can actually scroll to the this item - - const anchorRef = React.createRef(); - useScrollToActiveTOCItem({ anchorRef, isActive }); - return ( ; export function ScrollContainer(props: ScrollContainerProps) { - const { children, className, orientation, activeId } = props; + const { children, className, orientation, activeId, ...rest } = props; const containerRef = React.useRef(null); @@ -67,7 +67,7 @@ export function ScrollContainer(props: ScrollContainerProps) { container.removeEventListener('scroll', scrollListener); resizeObserver.disconnect(); }; - }, []); + }, [orientation]); // Scroll to the active item React.useEffect(() => { @@ -75,10 +75,11 @@ export function ScrollContainer(props: ScrollContainerProps) { if (!container || !activeId) { return; } - const activeItem = container.querySelector(`#${activeId}`); + const activeItem = container.querySelector(`#${CSS.escape(activeId)}`); if (activeItem) { activeItem.scrollIntoView({ inline: 'center', + block: 'center', }); } }, [activeId]); @@ -108,11 +109,15 @@ export function ScrollContainer(props: ScrollContainerProps) { }; return ( -
    +
    {/* Scrollable content */}
    0 ? orientation === 'horizontal' @@ -158,7 +163,7 @@ export function ScrollContainer(props: ScrollContainerProps) { className={tcls( orientation === 'horizontal' ? '-translate-y-1/2! top-1/2 right-0 mr-2' - : '-translate-x-1/2! bottom-0 left-1/2 mt-2', + : '-translate-x-1/2! bottom-0 left-1/2 mb-2', 'absolute not-pointer-none:block hidden scale-0 transition-[scale,opacity]', scrollPosition < scrollSize ? 'not-pointer-none:group-hover/scroll-container:scale-100 not-pointer-none:group-hover/scroll-container:opacity-11' From e49fe8de887d11f57ac4fae059d2156f46ee4e19 Mon Sep 17 00:00:00 2001 From: Zeno Kapitein Date: Wed, 17 Sep 2025 17:58:46 +0200 Subject: [PATCH 6/6] Update Header.tsx --- packages/gitbook/src/components/Header/Header.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/gitbook/src/components/Header/Header.tsx b/packages/gitbook/src/components/Header/Header.tsx index 2333c24bd8..b952f5d598 100644 --- a/packages/gitbook/src/components/Header/Header.tsx +++ b/packages/gitbook/src/components/Header/Header.tsx @@ -185,7 +185,7 @@ export function Header(props: {
    {withVariants === 'translations' ? ( -
    +