Initial UI for tooltip experiment

This commit is contained in:
Zeno Kapitein
2025-04-01 01:47:10 +02:00
parent 75855585f5
commit 37c1d4b8ea
9 changed files with 243 additions and 35 deletions
+5
View File
@@ -3,6 +3,9 @@
"workspaces": {
"": {
"name": "gitbook",
"dependencies": {
"@radix-ui/react-tooltip": "^1.1.8",
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.27.12",
@@ -836,6 +839,8 @@
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ=="],
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.1.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.5", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-popper": "1.2.2", "@radix-ui/react-portal": "1.1.4", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-slot": "1.1.2", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-visually-hidden": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YAA2cu48EkJZdAMHC0dqo9kialOcRStbtiY4nJPaht7Ptrhcvpo+eDChaM6BIs8kL6a8Z5l5poiqLnXcNduOkA=="],
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw=="],
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.1.0", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw=="],
+3
View File
@@ -38,5 +38,8 @@
"patchedDependencies": {
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch"
},
"dependencies": {
"@radix-ui/react-tooltip": "^1.1.8"
}
}
@@ -2,7 +2,11 @@ import { type DocumentInlineLink, SiteInsightsLinkPosition } from '@gitbook/api'
import { resolveContentRef } from '@/lib/references';
import { StyledLink } from '../primitives';
import { tcls } from '@/lib/tailwind';
import { Icon } from '@gitbook/icons';
import * as Tooltip from '@radix-ui/react-tooltip';
import { Fragment } from 'react';
import { Button, StyledLink } from '../primitives';
import type { InlineProps } from './Inline';
import { Inlines } from './Inlines';
@@ -10,7 +14,9 @@ export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
const { inline, document, context, ancestorInlines } = props;
const resolved = context.contentContext
? await resolveContentRef(inline.data.ref, context.contentContext)
? await resolveContentRef(inline.data.ref, context.contentContext, {
resolveAnchorText: true,
})
: null;
if (!resolved) {
@@ -26,23 +32,186 @@ export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
);
}
let breadcrumbs = resolved.ancestors;
const isExternal = inline.data.ref.kind === 'url';
if (isExternal) {
breadcrumbs = [
{
label: 'External link to',
},
];
}
return (
<StyledLink
href={resolved.href}
insights={{
type: 'link_click',
link: {
target: inline.data.ref,
position: SiteInsightsLinkPosition.Content,
},
}}
>
<Inlines
context={context}
document={document}
nodes={inline.nodes}
ancestorInlines={[...ancestorInlines, inline]}
/>
</StyledLink>
<Tooltip.Provider delayDuration={200}>
<Tooltip.Root>
<Tooltip.Trigger>
<StyledLink
href={resolved.href}
insights={{
type: 'link_click',
link: {
target: inline.data.ref,
position: SiteInsightsLinkPosition.Content,
},
}}
>
<Inlines
context={context}
document={document}
nodes={inline.nodes}
ancestorInlines={[...ancestorInlines, inline]}
/>
{isExternal ? (
<Icon
icon="arrow-up-right"
className="ml-0.5 inline size-3 links-accent:text-tint-subtle"
/>
) : null}
</StyledLink>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="z-40 flex w-[100vw] max-w-md animate-present px-4">
<div className="grow items-center overflow-hidden rounded-md straight-corners:rounded-none shadow-lg shadow-tint-12/4 ring-1 ring-tint-subtle dark:shadow-tint-1 ">
<div className="bg-tint-base p-4">
{breadcrumbs ? (
<div className="mb-1 flex gap-4">
<div className="flex grow flex-wrap items-center gap-x-2 gap-y-0.5 font-semibold text-tint text-xs uppercase leading-tight tracking-wide">
{breadcrumbs.map((crumb, index) => {
const Tag = crumb.href ? StyledLink : 'div';
return (
<Fragment key={crumb.label}>
{index !== 0 ? (
<Icon
icon="chevron-right"
className="size-3 text-tint-subtle"
/>
) : null}
<Tag
className={tcls(
'flex gap-1',
crumb.href &&
'links-default:text-tint no-underline hover:underline contrast-more:underline contrast-more:decoration-current'
)}
href={crumb.href ?? ''}
>
{crumb.icon ? (
<span className="mt-0.5 text-tint-subtle empty:hidden">
{crumb.icon}
</span>
) : null}
{crumb.label}
</Tag>
</Fragment>
);
})}
</div>
{resolved.href ? (
<Button
className="-mx-2 -my-2 ml-auto"
variant="blank"
href={resolved.href}
target="_blank"
label="Open in new tab"
size="small"
icon="arrow-up-right-from-square"
iconOnly={true}
/>
) : null}
</div>
) : null}
<div
className={tcls(
'flex gap-2 leading-snug',
isExternal && 'text-sm [overflow-wrap:anywhere]'
)}
>
{resolved.icon ? (
<div className="mt-1 text-tint-subtle empty:hidden">
{resolved.icon}
</div>
) : null}
<h5 className="font-semibold">{resolved.text}</h5>
</div>
{resolved.subText ? (
<p className="mt-1 text-sm text-tint">{resolved.subText}</p>
) : null}
</div>
{!isExternal ? (
<div className="border-tint-subtle border-t bg-tint p-4">
<div className="mb-1 flex items-center gap-1 font-semibold text-tint text-xs uppercase leading-tight tracking-wide">
<Icon icon="sparkle" className="size-3" />
<h6 className="text-tint">AI Summary</h6>
</div>
<p>
This integration synchronizes your docs with the code
repository so that your API spec updates in GitBook mirror
any changes made in GitHub or GitLab, keeping your content
aligned with your code.
</p>
<div className="-m-2 mt-0 flex flex-col rounded-md p-2 text-sm">
<details className="-mx-2 rounded-md px-2 py-1 transition-colors has-[summary:hover]:bg-tint-hover">
<summary className="flex items-center gap-1 text-tint hover:cursor-pointer">
<Icon
icon="circle-question"
className="size-3 shrink-0"
/>
Who can configure it?
</summary>
<div className="mt-1">
Administrators and creators set it up.
</div>
</details>
<details className="-mx-2 rounded-md px-2 py-1 transition-colors has-[summary:hover]:bg-tint-hover">
<summary className="flex items-center gap-1 text-tint hover:cursor-pointer">
<Icon
icon="circle-question"
className="size-3 shrink-0"
/>
Does it sync both ways?
</summary>
<div className="mt-1">
Edits in GitBook and commits on GitHub or GitLab
update each other.
</div>
</details>
<details className="-mx-2 rounded-md px-2 py-1 transition-colors has-[summary:hover]:bg-tint-hover">
<summary className="flex items-center gap-1 text-tint hover:cursor-pointer">
<Icon
icon="circle-question"
className="size-3 shrink-0"
/>
Which platforms connect?
</summary>
<div className="mt-1">
It works with GitHub and GitLab.
</div>
</details>
<div className="mt-2 flex gap-2">
<input
type="text"
className="w-full rounded-md px-4 py-1 text-sm ring-1 ring-tint-subtle"
placeholder="Ask AI about this page"
/>
<Button
variant="secondary"
size="small"
label="Ask question"
iconOnly={true}
icon="paper-plane-alt"
/>
</div>
</div>
</div>
) : null}
</div>
<Tooltip.Arrow className="fill-tint-1" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}
@@ -168,7 +168,7 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
key={index}
href={ref.href}
target="_blank"
style={['flex', 'flex-row', 'items-center', 'gap-2']}
className="flex flex-row items-center gap-2"
insights={
ref.file
? {
@@ -35,7 +35,7 @@ export async function PageHeader(props: {
<li key={breadcrumb.id}>
<StyledLink
href={href}
style={tcls(
className={tcls(
'no-underline',
'hover:underline',
'text-xs',
@@ -1,6 +1,6 @@
'use client';
import type { HTMLAttributes } from 'react';
import type { HTMLAttributeAnchorTarget, HTMLAttributes } from 'react';
import { type ClassValue, tcls } from '@/lib/tailwind';
@@ -28,10 +28,14 @@ const variantClasses = {
'contrast-more:ring-1',
],
blank: [
'bg-tint',
'bg-transparent',
'text-tint',
'ring-0',
'shadow-none',
'hover:bg-tint-hover',
'hover:text-primary',
'hover:scale-1',
'hover:shadow-none',
'contrast-more:bg-tint-subtle',
],
secondary: [
@@ -49,15 +53,16 @@ export function Button({
size = 'default',
className,
insights,
target,
label,
icon,
iconOnly = false,
...rest
}: ButtonProps) {
}: ButtonProps & { target?: HTMLAttributeAnchorTarget }) {
const sizes = {
default: ['text-base', 'px-4', 'py-2'],
medium: ['text-sm', 'px-3', 'py-1.5'],
small: ['text-xs', 'px-3 py-2'],
small: ['text-xs', 'py-2', iconOnly ? 'px-2' : 'px-3'],
};
const sizeClasses = sizes[size] || sizes.default;
@@ -65,6 +70,8 @@ export function Button({
const domClassName = tcls(
'button',
'inline-flex',
'items-center',
'gap-2',
'rounded-md',
'straight-corners:rounded-none',
'place-self-start',
@@ -102,9 +109,10 @@ export function Button({
className={domClassName}
insights={insights}
aria-label={label}
target={target}
{...rest}
>
{icon ? <Icon icon={icon} className={tcls('size-4')} /> : null}
{icon ? <Icon icon={icon} className={tcls('size-[1em]')} /> : null}
{iconOnly ? null : label}
</Link>
);
@@ -112,7 +120,7 @@ export function Button({
return (
<button type="button" className={domClassName} aria-label={label} {...rest}>
{icon ? <Icon icon={icon} className={tcls('size-4')} /> : null}
{icon ? <Icon icon={icon} className={tcls('size-[1em]')} /> : null}
{iconOnly ? null : label}
</button>
);
@@ -25,11 +25,11 @@ export const linkStyles = [
/**
* Styled version of Link component.
*/
export function StyledLink(props: Omit<LinkProps, 'style'> & { style?: ClassValue }) {
const { style, ...rest } = props;
export function StyledLink(props: Omit<LinkProps, 'style'> & { className?: ClassValue }) {
const { className, ...rest } = props;
return (
<Link {...rest} className={tcls(linkStyles, style)}>
<Link {...rest} className={tcls(linkStyles, className)}>
{props.children}
</Link>
);
+26 -3
View File
@@ -19,6 +19,7 @@ import { getGitbookAppHref } from './links';
import { resolvePageId } from './pages';
import { findSiteSpaceById } from './sites';
import type { ClassValue } from './tailwind';
import { filterOutNullable } from './typescript';
export interface ResolvedContentRef {
/** Text to render in the content ref */
@@ -29,6 +30,8 @@ export interface ResolvedContentRef {
icon?: React.ReactNode;
/** Emoji associated with the reference */
emoji?: string;
/** The content ref's ancestors */
ancestors?: { icon?: React.ReactNode; label: string; href?: string }[];
/** URL to open for the content ref */
href: string;
/** True if the content ref is active */
@@ -115,6 +118,14 @@ export async function resolveContentRef(
: resolvePageId(pages, contentRef.page);
const page = resolvePageResult?.page;
const ancestors =
resolvePageResult?.ancestors.map((ancestor) => ({
label: ancestor.title,
icon: <PageIcon page={ancestor} style={iconStyle} />,
href: resolveAsAbsoluteURL
? linker.toAbsoluteURL(linker.toPathForPage({ page: ancestor, pages }))
: linker.toPathForPage({ page: ancestor, pages }),
})) ?? [];
if (!page) {
return null;
}
@@ -125,10 +136,16 @@ export async function resolveContentRef(
let text = '';
let icon: React.ReactNode | undefined = undefined;
let emoji: string | undefined = undefined;
const href = linker.toPathForPage({ page, pages, anchor });
// Compute the text to display for the link
if (anchor) {
text = `#${anchor}`;
ancestors.push({
label: page.title,
icon: <PageIcon page={page} style={iconStyle} />,
href: resolveAsAbsoluteURL ? linker.toAbsoluteURL(href) : href,
});
if (resolveAnchorText) {
const document = await getPageDocument(dataFetcher, space, page);
@@ -151,11 +168,11 @@ export async function resolveContentRef(
icon = <PageIcon page={page} style={iconStyle} />;
}
const href = linker.toPathForPage({ page, pages, anchor });
return {
href: resolveAsAbsoluteURL ? linker.toAbsoluteURL(href) : href,
text,
subText: page.description,
ancestors: ancestors,
emoji,
icon,
active: !anchor && page.id === activePage?.id,
@@ -346,6 +363,12 @@ async function resolveContentRefInSpace(
return {
...resolved,
subText: space.title,
ancestors: [
{
label: space.title,
href: baseURL,
},
...(resolved.ancestors ?? []),
].filter(filterOutNullable),
};
}
+2 -2
View File
@@ -295,7 +295,7 @@ const config: Config = {
),
},
animation: {
present: 'present .5s ease-out both',
present: 'present 200ms cubic-bezier(0.25, 1, 0.5, 1) both',
scaleIn: 'scaleIn 200ms ease',
scaleOut: 'scaleOut 200ms ease',
fadeIn: 'fadeIn 200ms ease forwards',
@@ -330,7 +330,7 @@ const config: Config = {
present: {
from: {
opacity: '0',
transform: 'translateY(2rem) scale(0.9)',
transform: 'translateY(20%) scale(90%)',
},
to: {
opacity: '1',