Skip to content

Improve tabs link support #3211

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 9 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions packages/gitbook/src/components/DocumentView/HashLinkButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
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');

export function HashLinkButton(props: {
id: string;
block: DocumentBlockTabs | DocumentBlockHeading;
label?: string;
className?: ClassValue;
}) {
const { id, block, className, label = 'Direct link to block' } = props;
const textStyle = getBlockTextStyle(block);
return (
<div
className={tcls(
'hash',
'grid',
'grid-area-1-1',
'w-7',
'h-[1em]',
'border-0',
'opacity-0',
'group-hover:opacity-[0]',
'group-focus:opacity-[0]',
'md:group-hover:md:opacity-[1]',
'md:group-focus: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-4',
'mt-1',
'self-center',
'transition-colors',
'text-transparent',
'group-hover:text-tint-subtle',
'contrast-more:group-hover:text-tint-strong'
)}
/>
</a>
</div>
);
}
49 changes: 9 additions & 40 deletions packages/gitbook/src/components/DocumentView/Heading.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -23,50 +23,19 @@ 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('relative', '-ml-6', textStyle.marginTop)}
label="Direct link to heading"
/>

<div
className={tcls(
'grid-area-1-1',
Expand Down
199 changes: 105 additions & 94 deletions packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -138,107 +141,115 @@ export function DynamicTabs(
}, [hash, tabs, onSelectTab]);

return (
<div
className={tcls(
'rounded-lg',
'straight-corners:rounded-sm',
'ring-1',
'ring-inset',
'ring-tint-subtle',
'flex',
'overflow-hidden',
'flex-col',
style
)}
>
<div className={tcls(hashLinkButtonWrapperStyles, 'flex-1', style)}>
<HashLinkButton
id={getTabButtonId(active.id)}
block={block}
className={tcls('absolute', '-left-6')}
label="Direct link to selected tab"
/>

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note to reviewer: the code below was not touched, the highlighted changes below are due to wrapping the code in this new div container above.

<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'
'rounded-lg',
'straight-corners:rounded-sm',
'ring-1',
'ring-inset',
'ring-tint-subtle',
'flex',
'overflow-hidden',
'flex-col'
)}
>
{tabs.map((tab) => (
<button
<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) => (
<button
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(
//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',

'inline-block',
'text-sm',
'px-3.5',
'py-2',
'transition-[color]',
'font-[500]',
'relative',

'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-[1px]',

'last:after:border-transparent',

'text-tint',
'bg-tint-12/1',
'hover:text-tint-strong',

'truncate',
'max-w-full',

active.id === tab.id
? [
'shrink-0',
'active-tab',
'text-tint-strong',
'bg-transparent',
'after:[&.active-tab]:border-transparent',
'after:[:has(+_&.active-tab)]:border-transparent',
'after:[:has(&_+)]:border-transparent',
]
: null
)}
>
{tab.title}
</button>
))}
</div>
{tabs.map((tab, index) => (
<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(
//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',

'inline-block',
'text-sm',
'px-3.5',
'py-2',
'transition-[color]',
'font-[500]',
'relative',

'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-[1px]',

'last:after:border-transparent',

'text-tint',
'bg-tint-12/1',
'hover:text-tint-strong',

'truncate',
'max-w-full',

active.id === tab.id
? [
'shrink-0',
'active-tab',
'text-tint-strong',
'bg-transparent',
'after:[&.active-tab]:border-transparent',
'after:[:has(+_&.active-tab)]:border-transparent',
'after:[:has(&_+)]:border-transparent',
]
: null
)}
role="tabpanel"
id={getTabPanelId(tab.id)}
aria-labelledby={getTabButtonId(tab.id)}
className={tcls('p-4', tab.id !== active.id ? 'hidden' : null)}
>
{tab.title}
</button>
{tabsBody[index]}
</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>
))}
</div>
);
}
Expand Down
5 changes: 4 additions & 1 deletion packages/gitbook/src/components/DocumentView/Tabs/Tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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} />
);
}