Improve tabs (#3735)

This commit is contained in:
Greg Bergé
2025-10-17 12:13:09 +02:00
committed by GitHub
parent 454175ab86
commit c0d73d2f76
8 changed files with 457 additions and 227 deletions
@@ -51,7 +51,7 @@ export function Expandable(props: BlockProps<DocumentBlockExpandable>) {
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<DocumentBlockExpandable>) {
document={document}
ancestorBlocks={[...ancestorBlocks, block]}
context={context}
style={['px-10', 'pb-5', 'space-y-4']}
style="space-y-4 px-10 pb-5"
/>
</Details>
);
@@ -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
)}
>
<a
<Link
href={`#${id}`}
aria-label={label}
className={tcls('inline-flex', 'h-full', 'items-start', textStyle.lineHeight)}
className={tcls('inline-flex h-full items-start', textStyle.lineHeight)}
>
<Icon
icon="hashtag"
@@ -52,7 +55,7 @@ export function HashLinkButton(props: {
iconClassName
)}
/>
</a>
</Link>
</div>
);
}
@@ -1,12 +1,20 @@
'use client';
import React, { useCallback, useMemo } from 'react';
import React, { memo, useCallback, useMemo, type ComponentPropsWithRef } from 'react';
import { useHash, useIsMounted } from '@/components/hooks';
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<Type> = {
[Property in keyof Type]: Type[Property];
};
type TabsInput = {
id: string;
tabs: SelectorMapper<TabsItem>[];
};
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,179 +92,293 @@ 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;
}
if (hash) {
// First check if the hash matches a tab ID.
const hashIsTab = tabs.some((tab) => tab.id === hash);
if (hashIsTab) {
selectTab(hash);
return;
}
const activeElement = document.getElementById(hash);
if (!activeElement) {
return;
}
// Then check if the hash matches an element inside a tab.
const activeElement = document.getElementById(hash);
if (!activeElement) {
return;
}
const tabAncestor = activeElement.closest('[role="tabpanel"]');
if (!tabAncestor) {
return;
}
const tabPanel = activeElement.closest('[role="tabpanel"]');
if (!tabPanel) {
return;
}
const tab = tabs.find((tab) => getTabPanelId(tab.id) === tabAncestor.id);
if (!tab) {
return;
selectTab(tabPanel.id);
}
onSelectTab(tab);
}, [hash, tabs, onSelectTab]);
}, [selectTab, tabs, hash]);
return (
<div
className={tcls(
'rounded-lg',
'straight-corners:rounded-xs',
'ring-1',
'ring-inset',
'ring-tint-subtle',
'flex',
'flex-col',
'overflow-hidden',
style
'ring-1 ring-tint-subtle ring-inset',
'flex min-w-0 flex-col',
className
)}
>
<div
role="tablist"
className={tcls(
'group/tabs',
'inline-flex',
'flex-row',
'self-stretch',
'after:flex-1',
'after:bg-tint-12/1',
// if last tab is selected, apply rounded to :after element
'[&:has(button.active-tab:last-of-type):after]:rounded-bl-md'
)}
>
{tabs.map((tab) => (
<div
key={tab.id}
className={tcls(
hashLinkButtonWrapperStyles,
'flex',
'items-center',
'gap-3.5',
//prev from active-tab
'[&:has(+_.active-tab)]:rounded-br-md',
//next from active-tab
'[.active-tab+&]:rounded-bl-md',
//next from active-tab
'[.active-tab_+_:after]:rounded-br-md',
'after:transition-colors',
'after:border-r',
'after:absolute',
'after:left-[unset]',
'after:right-0',
'after:border-tint',
'after:top-[15%]',
'after:h-[70%]',
'after:w-px',
'px-3.5',
'py-2',
'last:after:border-transparent',
'text-tint',
'bg-tint-12/1',
'hover:text-tint-strong',
'max-w-full',
'truncate',
active?.id === tab.id
? [
'shrink-0',
'active-tab',
'text-tint-strong',
'bg-transparent',
'[&.active-tab]:after:border-transparent',
'[:has(+_&.active-tab)]:after:border-transparent',
'[:has(&_+)]:after:border-transparent',
]
: null
)}
>
<button
type="button"
role="tab"
aria-selected={active?.id === tab.id}
aria-controls={getTabPanelId(tab.id)}
id={getTabButtonId(tab.id)}
onClick={() => {
onSelectTab(tab);
}}
className={tcls(
'inline-block',
'text-sm',
'transition-[color]',
'font-medium',
'relative',
'max-w-full',
'truncate'
)}
>
{tab.title}
</button>
<HashLinkButton
id={getTabButtonId(tab.id)}
block={block}
label="Direct link to tab"
/>
</div>
))}
</div>
{tabs.map((tab, index) => (
<div
key={tab.id}
role="tabpanel"
id={getTabPanelId(tab.id)}
aria-labelledby={getTabButtonId(tab.id)}
className={tcls('p-4', tab.id !== active?.id ? 'hidden' : null)}
>
{tabsBody[index]}
</div>
<TabItemList tabs={tabs} activeTabId={active?.id ?? null} onSelect={selectTab} />
{tabs.map((tab) => (
<TabPanel key={tab.id} tab={tab} isActive={tab.id === active?.id} />
))}
</div>
);
}
const TabPanel = memo(function TabPanel(props: {
tab: TabsItem;
isActive: boolean;
}) {
const { tab, isActive } = props;
return (
<div
role="tabpanel"
id={tab.id}
aria-labelledby={getTabButtonId(tab.id)}
className={tcls('p-4', isActive ? null : 'hidden')}
>
{tab.body}
</div>
);
});
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 (
<div
ref={containerRef}
role="tablist"
className={tcls(
'group/tabs',
'overflow-hidden',
'rounded-t-lg',
'straight-corners:rounded-t-xs',
'inline-flex',
'self-stretch',
'after:flex-1',
'after:bg-tint-12/1',
// if last tab is selected, apply rounded to :after element
'[&: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 ? (
<TabsDropdownMenu tabs={tabs} onSelect={onSelect} activeTabId={activeTabId} />
) : null}
{tabs.map((tab) => {
// Hide overflowing tabs when not measuring.
if (overflowing.has(getTabButtonId(tab.id)) && !isMeasuring) {
return null;
}
return (
<TabItem
key={tab.id}
ref={itemRef}
isActive={tab.id === activeTabId}
tab={tab}
onSelect={onSelect}
/>
);
})}
{/* Dropdown for overflowing tabs */}
{overflowingTabs.length > 0 && !isMeasuring ? (
<TabsDropdownMenu
tabs={overflowingTabs}
onSelect={onSelect}
activeTabId={activeTabId}
/>
) : null}
</div>
);
});
function TabsDropdownMenu(props: {
tabs: TabsItem[];
activeTabId: string | null;
onSelect: (tabId: string) => void;
}) {
const { tabs, onSelect, activeTabId } = props;
const language = useLanguage();
return (
<DropdownMenu
button={
<TabButton
isActive={tabs.some((tab) => tab.id === activeTabId)}
aria-label={tString(language, 'more')}
className="shrink-0"
>
<Icon icon="ellipsis" className="size-4" />
</TabButton>
}
>
{tabs.map((tab) => {
return (
<DropdownMenuItem
key={tab.id}
onClick={() => onSelect(tab.id)}
active={tab.id === activeTabId}
>
{tab.title}
</DropdownMenuItem>
);
})}
</DropdownMenu>
);
}
/**
* Tab item that accepts a `tab` prop.
*/
const TabItem = memo(function TabItem(props: {
ref: React.Ref<HTMLButtonElement>;
isActive: boolean;
tab: TabsItem;
onSelect: (tabId: string) => void;
}) {
const { ref, tab, isActive, onSelect } = props;
return (
<TabButton
ref={ref}
role="tab"
aria-selected={isActive}
aria-controls={tab.id}
id={getTabButtonId(tab.id)}
onClick={() => onSelect(tab.id)}
>
{tab.title}
</TabButton>
);
});
/**
* Generic tab button component, low-level.
*/
function TabButton(
props: Omit<ComponentPropsWithRef<'button'>, 'type'> & {
isActive?: boolean;
}
) {
const { isActive, ...rest } = props;
return (
<div
className={tcls(
'relative',
'flex items-center',
//prev from active-tab
'[&:has(+_.active-tab)]:rounded-br-md',
//next from active-tab
'[.active-tab+&]:rounded-bl-md',
//next from active-tab
'[.active-tab_+_:after]:rounded-br-md',
'after:transition-colors',
'after:border-r',
'after:absolute',
'after:left-[unset]',
'after:right-0',
'after:border-tint',
'after:top-[15%]',
'after:h-[70%]',
'after:w-px',
'last:after:border-transparent',
'text-tint',
'bg-tint-12/1',
'hover:text-tint-strong',
'max-w-full',
'shrink-0',
'truncate',
props['aria-selected'] || props['aria-expanded'] || isActive
? [
'active-tab',
'text-tint-strong',
'bg-transparent',
'[&.active-tab]:after:border-transparent',
'[:has(+_&.active-tab)]:after:border-transparent',
'[:has(&_+)]:after:border-transparent',
]
: null
)}
>
<button
{...rest}
type="button"
className={tcls(
'relative inline-block max-w-full truncate px-3.5 py-2 font-medium text-sm transition-[color]',
props.className
)}
/>
</div>
);
}
/**
* Get the ID for a tab button.
*/
@@ -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) => {
@@ -9,47 +9,40 @@ import { DynamicTabs, type TabsItem } from './DynamicTabs';
export function Tabs(props: BlockProps<DocumentBlockTabs>) {
const { block, ancestorBlocks, document, style, context } = props;
const tabs: TabsItem[] = [];
const tabsBody: React.ReactNode[] = [];
block.nodes.forEach((tab, index) => {
tabs.push({
id: tab.meta?.id ?? tab.key!,
title: tab.data.title ?? '',
});
tabsBody.push(
<Blocks
key={tab.key ?? index}
nodes={tab.nodes}
document={document}
ancestorBlocks={[...ancestorBlocks, block, tab]}
context={context}
blockStyle={tcls('flip-heading-hash')}
style={tcls('w-full', 'space-y-4')}
/>
);
});
if (context.mode === 'print') {
// When printing, we display the tab, one after the other
return (
<>
{tabs.map((tab, index) => (
<DynamicTabs
key={tab.id}
id={block.key!}
block={block}
tabs={[tab]}
tabsBody={[tabsBody[index]]}
style={style}
/>
))}
</>
);
if (!block.key) {
throw new Error('Tabs block is missing a key');
}
return (
<DynamicTabs id={block.key!} block={block} tabs={tabs} tabsBody={tabsBody} style={style} />
);
const id = block.key;
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: (
<Blocks
key={tab.key}
nodes={tab.nodes}
document={document}
ancestorBlocks={[...ancestorBlocks, block, tab]}
context={context}
blockStyle="flip-heading-hash"
style="w-full space-y-4"
/>
),
};
});
// When printing, we display the tab, one after the other
if (context.mode === 'print') {
return tabs.map((tab) => {
return <DynamicTabs key={tab.id} id={id} tabs={[tab]} className={tcls(style)} />;
});
}
return <DynamicTabs id={id} tabs={tabs} className={tcls(style)} />;
}
@@ -7,3 +7,4 @@ export * from './useCurrentPagePath';
export * from './useCurrentContent';
export * from './useCurrentPage';
export * from './useNow';
export * from './useListOverflow';
@@ -61,7 +61,7 @@ export const NavigationStatusProvider: React.FC<React.PropsWithChildren> = ({ 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<React.PropsWithChildren> = ({ ch
*/
export function useHash() {
const { hash } = React.useContext(NavigationStatusContext);
return hash;
}
@@ -0,0 +1,100 @@
'use client';
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
interface OverflowState {
/**
* Ref for the container element.
*/
containerRef: React.RefObject<HTMLDivElement | null>;
/**
* Ref callback for each item in the list.
*/
itemRef: (element: HTMLElement | null) => void;
/**
* Set of IDs that are currently overflowing.
*/
overflowing: Set<string>;
/**
* 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<HTMLDivElement>(null);
const [overflowing, setOverflowing] = useState<Set<string>>(new Set());
const [isMeasuring, setIsMeasuring] = useState(false);
const itemRefs = useRef(new Map<string, HTMLElement>());
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<string>();
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 };
}
@@ -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');