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" /> ); diff --git a/packages/gitbook/src/components/DocumentView/HashLinkButton.tsx b/packages/gitbook/src/components/DocumentView/HashLinkButton.tsx index 66718ecef2..3f6f42f3d0 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'; /** @@ -28,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]', @@ -35,10 +38,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..ad0dd304f3 100644 --- a/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx +++ b/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx @@ -1,12 +1,20 @@ 'use client'; -import React, { useCallback, useMemo } from 'react'; - -import { 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 { HashLinkButton, hashLinkButtonWrapperStyles } from '../HashLinkButton'; +import { tcls } from '@/lib/tailwind'; +import { Icon } from '@gitbook/icons'; +import { useRouter } from 'next/navigation'; interface TabsState { activeIds: { @@ -46,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; @@ -66,14 +67,14 @@ 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, block, 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); const hash = useHash(); const [tabsState, setTabsState] = useTabsState(); @@ -91,175 +92,289 @@ 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 - */ - 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, - })); + // 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); + + 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. - */ + // 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; - } - - const tab = tabs.find((tab) => getTabPanelId(tab.id) === tabAncestor.id); - if (!tab) { - return; + if (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); } - - onSelectTab(tab); - }, [hash, tabs, onSelectTab]); + }, [selectTab, tabs, hash]); return (
+ + {tabs.map((tab) => ( + + ))} +
+ ); +} + +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; + onSelect: (tabId: string) => 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 ( +
-
- {tabs.map((tab) => ( -
+ ) : null} + {tabs.map((tab) => { + // Hide overflowing tabs when not measuring. + if (overflowing.has(getTabButtonId(tab.id)) && !isMeasuring) { + return null; + } + return ( + - - - -
- ))} -
- {tabs.map((tab, index) => ( -
+ ); + })} + {/* Dropdown for overflowing tabs */} + {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" > - {tabsBody[index]} -
- ))} + + + } + > + {tabs.map((tab) => { + return ( + onSelect(tab.id)} + active={tab.id === activeTabId} + > + {tab.title} + + ); + })} + + ); +} + +/** + * Tab item that accepts a `tab` prop. + */ +const TabItem = memo(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} + + ); +}); + +/** + * Generic tab button component, low-level. + */ +function TabButton( + props: Omit, 'type'> & { + isActive?: boolean; + } +) { + const { isActive, ...rest } = props; + return ( +
+
); } @@ -272,17 +387,25 @@ function getTabButtonId(tabId: string) { } /** - * Get the ID for a tab panel. - * We use the ID of the tab itself as links can be pointing to this ID. + * Get the ID of a tab from a button ID. */ -function getTabPanelId(tabId: string) { - return tabId; +function getTabIdFromButtonId(buttonId: string) { + if (buttonId.startsWith('tab-')) { + return buttonId.slice(4); + } + return buttonId; } /** * 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; } @@ -290,7 +413,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..a7b85e23be 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 ( - - ); + 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/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/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 }; +} 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');