mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-10-03 20:20:35 +00:00
Initial UI for tooltip experiment
This commit is contained in:
@@ -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=="],
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user