mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-21 01:53:26 +00:00
Improve tabs (#3735)
This commit is contained in:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user