Improve tabs link support (#3211)

This commit is contained in:
Claire Chabas
2025-05-14 11:10:09 +02:00
committed by GitHub
parent 80cb52a237
commit 08382ce62f
5 changed files with 120 additions and 63 deletions
@@ -0,0 +1,58 @@
import { type ClassValue, tcls } from '@/lib/tailwind';
import type { DocumentBlockHeading, DocumentBlockTabs } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import { getBlockTextStyle } from './spacing';
/**
* A hash icon which adds the block or active block item's ID in the URL hash.
* The button needs to be wrapped in a container with `hashLinkButtonWrapperStyles`.
*/
export const hashLinkButtonWrapperStyles = tcls('relative', 'group/hash');
export function HashLinkButton(props: {
id: string;
block: DocumentBlockTabs | DocumentBlockHeading;
label?: string;
className?: ClassValue;
iconClassName?: ClassValue;
}) {
const { id, block, className, iconClassName, label = 'Direct link to block' } = props;
const textStyle = getBlockTextStyle(block);
return (
<div
className={tcls(
'relative',
'hash',
'grid',
'grid-area-1-1',
'h-[1em]',
'border-0',
'opacity-0',
'group-hover/hash:opacity-[0]',
'group-focus/hash:opacity-[0]',
'md:group-hover/hash:md:opacity-[1]',
'md:group-focus/hash:md:opacity-[1]',
className
)}
>
<a
href={`#${id}`}
aria-label={label}
className={tcls('inline-flex', 'h-full', 'items-start', textStyle.lineHeight)}
>
<Icon
icon="hashtag"
className={tcls(
'size-3',
'self-center',
'transition-colors',
'text-transparent',
'group-hover/hash:text-tint-subtle',
'contrast-more:group-hover/hash:text-tint-strong',
iconClassName
)}
/>
</a>
</div>
);
}
@@ -1,9 +1,9 @@
import type { DocumentBlockHeading } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import { tcls } from '@/lib/tailwind';
import type { BlockProps } from './Block';
import { HashLinkButton, hashLinkButtonWrapperStyles } from './HashLinkButton';
import { Inlines } from './Inlines';
import { getBlockTextStyle } from './spacing';
@@ -23,50 +23,20 @@ export function Heading(props: BlockProps<DocumentBlockHeading>) {
className={tcls(
textStyle.textSize,
'heading',
'group',
'relative',
'grid',
'scroll-m-12',
hashLinkButtonWrapperStyles,
style
)}
>
<div
className={tcls(
'hash',
'grid',
'grid-area-1-1',
'relative',
'-ml-6',
'w-7',
'border-0',
'opacity-0',
'group-hover:opacity-[0]',
'group-focus:opacity-[0]',
'md:group-hover:md:opacity-[1]',
'md:group-focus:md:opacity-[1]',
textStyle.marginTop
)}
>
<a
href={`#${id}`}
aria-label="Direct link to heading"
className={tcls('inline-flex', 'h-full', 'items-start', textStyle.lineHeight)}
>
<Icon
icon="hashtag"
className={tcls(
'w-3.5',
'h-[1em]',
'mt-0.5',
'transition-colors',
'text-transparent',
'group-hover:text-tint-subtle',
'contrast-more:group-hover:text-tint-strong',
'lg:w-4'
)}
/>
</a>
</div>
<HashLinkButton
id={id}
block={block}
className={tcls('-ml-6', textStyle.anchorButtonMarginTop)}
iconClassName={tcls('size-4')}
label="Direct link to heading"
/>
<div
className={tcls(
'grid-area-1-1',
@@ -5,6 +5,8 @@ import React, { useCallback, useMemo } from 'react';
import { useHash, useIsMounted } from '@/components/hooks';
import * as storage from '@/lib/local-storage';
import { type ClassValue, tcls } from '@/lib/tailwind';
import type { DocumentBlockTabs } from '@gitbook/api';
import { HashLinkButton, hashLinkButtonWrapperStyles } from '../HashLinkButton';
interface TabsState {
activeIds: {
@@ -68,9 +70,10 @@ export function DynamicTabs(
props: TabsInput & {
tabsBody: React.ReactNode[];
style: ClassValue;
block: DocumentBlockTabs;
}
) {
const { id, tabs, tabsBody, style } = props;
const { id, block, tabs, tabsBody, style } = props;
const hash = useHash();
const [tabsState, setTabsState] = useTabsState();
@@ -146,8 +149,8 @@ export function DynamicTabs(
'ring-inset',
'ring-tint-subtle',
'flex',
'overflow-hidden',
'flex-col',
'overflow-hidden',
style
)}
>
@@ -165,16 +168,14 @@ export function DynamicTabs(
)}
>
{tabs.map((tab) => (
<button
<div
key={tab.id}
role="tab"
aria-selected={active.id === tab.id}
aria-controls={getTabPanelId(tab.id)}
id={getTabButtonId(tab.id)}
onClick={() => {
onSelectTab(tab);
}}
className={tcls(
hashLinkButtonWrapperStyles,
'flex',
'items-center',
'gap-3.5',
//prev from active-tab
'[&:has(+_.active-tab)]:rounded-br-md',
@@ -184,14 +185,6 @@ export function DynamicTabs(
//next from active-tab
'[.active-tab_+_:after]:rounded-br-md',
'inline-block',
'text-sm',
'px-3.5',
'py-2',
'transition-[color]',
'font-[500]',
'relative',
'after:transition-colors',
'after:border-r',
'after:absolute',
@@ -202,14 +195,16 @@ export function DynamicTabs(
'after:h-[70%]',
'after:w-[1px]',
'px-3.5',
'py-2',
'last:after:border-transparent',
'text-tint',
'bg-tint-12/1',
'hover:text-tint-strong',
'truncate',
'max-w-full',
'truncate',
active.id === tab.id
? [
@@ -224,8 +219,34 @@ export function DynamicTabs(
: null
)}
>
{tab.title}
</button>
<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-[500]',
'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) => (
@@ -39,6 +39,7 @@ export function Tabs(props: BlockProps<DocumentBlockTabs>) {
<DynamicTabs
key={tab.id}
id={block.key!}
block={block}
tabs={[tab]}
tabsBody={[tabsBody[index]]}
style={style}
@@ -48,5 +49,7 @@ export function Tabs(props: BlockProps<DocumentBlockTabs>) {
);
}
return <DynamicTabs id={block.key!} tabs={tabs} tabsBody={tabsBody} style={style} />;
return (
<DynamicTabs id={block.key!} block={block} tabs={tabs} tabsBody={tabsBody} style={style} />
);
}
@@ -10,6 +10,8 @@ export function getBlockTextStyle(block: DocumentBlock): {
lineHeight: string;
/** Tailwind class for the margin top (mt-*) */
marginTop?: string;
/** Tailwind class for the margin top to apply on the anchor link button */
anchorButtonMarginTop?: string;
} {
switch (block.type) {
case 'paragraph':
@@ -22,18 +24,21 @@ export function getBlockTextStyle(block: DocumentBlock): {
textSize: 'text-3xl font-semibold',
lineHeight: 'leading-tight',
marginTop: 'mt-[1em]',
anchorButtonMarginTop: 'mt-[1.05em]',
};
case 'heading-2':
return {
textSize: 'text-2xl font-semibold',
lineHeight: 'leading-snug',
marginTop: 'mt-[0.75em]',
anchorButtonMarginTop: 'mt-[0.9em]',
};
case 'heading-3':
return {
textSize: 'text-xl font-semibold',
lineHeight: 'leading-snug',
marginTop: 'mt-[0.5em]',
anchorButtonMarginTop: 'mt-[0.65em]',
};
case 'divider':
return {