Fix admin toolbar magnification, tooltips and truncation

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Viktor Renkema
2026-10-20 13:06:31 +02:00
parent 81dba6455b
commit 65fa5b61ba
11 changed files with 304 additions and 320 deletions
@@ -15,7 +15,6 @@ import {
ToolbarBody, ToolbarBody,
ToolbarButton, ToolbarButton,
ToolbarButtonGroup, ToolbarButtonGroup,
type ToolbarButtonProps,
ToolbarSubtitle, ToolbarSubtitle,
ToolbarTitle, ToolbarTitle,
} from './Toolbar'; } from './Toolbar';
@@ -137,14 +136,21 @@ function ChangeRequestToolbar(props: ToolbarViewProps) {
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}> <Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
<ToolbarBody> <ToolbarBody>
<ToolbarTitle <ToolbarTitle
prefix={`Change #${changeRequest.number}:`} prefix={`#${changeRequest.number}`}
suffix={`${changeRequest.subject || 'Untitled'}`} prefixLabel={`Change request #${changeRequest.number}`}
suffix={changeRequest.subject || 'Untitled'}
/> />
<ToolbarSubtitle <ToolbarSubtitle
subtitle={ subtitle={
<> <>
<ToolbarDate value={changeRequest.updatedAt} />{' '} <ToolbarDate value={changeRequest.updatedAt} />{' '}
<motion.span layout="position">by {author}</motion.span> <motion.span
layout="position"
title={author}
className="min-w-0 truncate"
>
by {author}
</motion.span>
</> </>
} }
/> />
@@ -339,12 +345,8 @@ function ToolbarActions(props: { children: React.ReactNode }) {
); );
} }
function EditPageButton(props: { function EditPageButton(props: { href: string; siteId: string }) {
href: string; const { href, siteId } = props;
siteId: string;
motionValues?: ToolbarButtonProps['motionValues'];
}) {
const { href, motionValues, siteId } = props;
const pagePath = useCurrentPagePath(); const pagePath = useCurrentPagePath();
return ( return (
@@ -356,7 +358,6 @@ function EditPageButton(props: {
buttonId: 'edit', buttonId: 'edit',
})} })}
icon="pen-to-square" icon="pen-to-square"
motionValues={motionValues}
/> />
); );
} }
@@ -1,5 +1,6 @@
.svgLogo { .svgLogo {
--logo-fill: var(--color-neutral-100); /* The toolbar is always dark, so the logo is always light. */
--logo-fill: rgb(255 255 255 / 0.92);
--seg-A-color: #2782c4; --seg-A-color: #2782c4;
--seg-B-color: #43b7f2; --seg-B-color: #43b7f2;
--seg-C-color: #8be2ff; --seg-C-color: #8be2ff;
@@ -11,8 +11,14 @@ export const AnimatedLogo: React.FC<AnimatedLogoProps> = (props) => {
const { shouldAnimate = true } = props; const { shouldAnimate = true } = props;
return ( return (
// Light even without the CSS module, which loads with the lazy toolbar chunk and can be
// missing (e.g. after a hot reload): an SVG path without a fill renders black.
<svg <svg
className={tcls(styles.svgLogo, shouldAnimate ? undefined : styles.static)} className={tcls(
'text-white/90',
styles.svgLogo,
shouldAnimate ? undefined : styles.static
)}
width="28" width="28"
height="28" height="28"
viewBox="0 0 28 28" viewBox="0 0 28 28"
@@ -22,6 +28,7 @@ export const AnimatedLogo: React.FC<AnimatedLogoProps> = (props) => {
<path <path
d={dPath} d={dPath}
transform="translate(14,14) scale(0.4)" transform="translate(14,14) scale(0.4)"
fill="currentColor"
strokeWidth={3} strokeWidth={3}
pathLength={1} pathLength={1}
strokeLinecap="round" strokeLinecap="round"
@@ -6,8 +6,9 @@ import type { SVGProps } from 'react';
import { Icon, type IconName, IconStyle } from '@gitbook/icons'; import { Icon, type IconName, IconStyle } from '@gitbook/icons';
import { DropdownMenu, DropdownMenuItem, DropdownMenuSeparator } from '../primitives'; import { DropdownMenu, DropdownMenuItem, DropdownMenuSeparator } from '../primitives';
import type { ToolbarButtonProps } from './Toolbar'; import { useToolbarSlot } from './Toolbar';
import type { MinimalChangedPage, MinimalChangedPages } from './types'; import type { MinimalChangedPage, MinimalChangedPages } from './types';
import { TOOLTIP_OFFSET } from './useMagnificationEffect';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
const STATUS_ICON_STYLES: Record< const STATUS_ICON_STYLES: Record<
@@ -42,12 +43,10 @@ const STATUS_ICON_CHIP_CLASS = 'flex size-5 shrink-0 items-center justify-center
/** /**
* A button that opens a popover with quick links to the _changed_ pages. * A button that opens a popover with quick links to the _changed_ pages.
*/ */
export function ChangedPagesButton(props: { export function ChangedPagesButton(props: { changedPages: MinimalChangedPages | null }) {
changedPages: MinimalChangedPages | null; const { changedPages } = props;
motionValues?: ToolbarButtonProps['motionValues'];
}) {
const { changedPages, motionValues } = props;
const reduceMotion = useReducedMotion(); const reduceMotion = useReducedMotion();
const slot = useToolbarSlot();
if (!changedPages?.pages.length) { if (!changedPages?.pages.length) {
return null; return null;
@@ -59,30 +58,19 @@ export function ChangedPagesButton(props: {
}`; }`;
const trigger = ( const trigger = (
<button <motion.button
type="button" type="button"
aria-label="View changed pages" aria-label="View changed pages"
style={
reduceMotion || !slot
? undefined
: { scale: slot.scale, x: slot.x, transformOrigin: 'bottom center' }
}
className="relative size-8 cursor-pointer border-0 bg-transparent p-0" className="relative size-8 cursor-pointer border-0 bg-transparent p-0"
> >
<motion.span <span
aria-hidden="true" aria-hidden="true"
style={
reduceMotion
? undefined
: {
scale: motionValues?.scale,
x: motionValues?.x,
transformOrigin: 'bottom center',
zIndex: motionValues?.scale ? 10 : 'auto',
}
}
transition={{
type: 'spring',
stiffness: 400,
damping: 30,
}}
className={tcls( className={tcls(
'toolbar-button',
'relative flex size-8 items-center justify-center gap-1 truncate rounded-full text-sm transition-colors', 'relative flex size-8 items-center justify-center gap-1 truncate rounded-full text-sm transition-colors',
'text-tint-7 hover:text-tint-1', 'text-tint-7 hover:text-tint-1',
'dark:text-tint-12', 'dark:text-tint-12',
@@ -91,14 +79,18 @@ export function ChangedPagesButton(props: {
)} )}
> >
<DiffIcon className="size-4 shrink-0" /> <DiffIcon className="size-4 shrink-0" />
</motion.span> </span>
</button> </motion.button>
); );
return ( return (
<DropdownMenu <DropdownMenu
button={trigger} button={trigger}
buttonTooltip="View changed pages" buttonTooltip="View changed pages"
// Like the other toolbar buttons: against the unscaled slot, with room to magnify.
buttonTooltipProps={
slot ? { anchor: slot.anchorRef, sideOffset: TOOLTIP_OFFSET } : undefined
}
side="top" side="top"
align="end" align="end"
sideOffset={12} sideOffset={12}
@@ -5,7 +5,7 @@ import { useOnClickOutside } from 'usehooks-ts';
import { Icon, type IconName, IconStyle } from '@gitbook/icons'; import { Icon, type IconName, IconStyle } from '@gitbook/icons';
import { ToolbarButton, type ToolbarButtonProps } from './Toolbar'; import { ToolbarButton, useToolbarSlot } from './Toolbar';
import styles from './Toolbar.module.css'; import styles from './Toolbar.module.css';
import { useToolbarControls } from './ToolbarControlsContext'; import { useToolbarControls } from './ToolbarControlsContext';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
@@ -26,15 +26,11 @@ const ARC_PARAMS = {
offsetAnchorY: 40, offsetAnchorY: 40,
} as const; } as const;
interface HideToolbarButtonProps {
motionValues?: ToolbarButtonProps['motionValues'];
}
/** /**
* Hide menu trigger. Expands a macOS Dock-like submenu with 3 labeled actions. * Hide menu trigger. Expands a macOS Dock-like submenu with 3 labeled actions.
*/ */
export function HideToolbarButton(props: HideToolbarButtonProps) { export function HideToolbarButton() {
const { motionValues } = props; const slot = useToolbarSlot();
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const [closing, setClosing] = React.useState(false); const [closing, setClosing] = React.useState(false);
const controls = useToolbarControls(); const controls = useToolbarControls();
@@ -114,11 +110,8 @@ export function HideToolbarButton(props: HideToolbarButtonProps) {
: null, : null,
].filter(Boolean) as ArcMenuItem[]; ].filter(Boolean) as ArcMenuItem[];
const sharedMotionStyle = motionValues // The arc follows the button's push, but not its scale.
? { const sharedMotionStyle = slot ? { x: slot.x } : undefined;
x: motionValues.x,
}
: undefined;
return ( return (
<ToolbarButton <ToolbarButton
@@ -136,7 +129,6 @@ export function HideToolbarButton(props: HideToolbarButtonProps) {
setOpen(true); setOpen(true);
} }
}} }}
motionValues={motionValues}
icon="gear" icon="gear"
> >
{/* Expanding arc menu */} {/* Expanding arc menu */}
@@ -1,16 +1,13 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import { ToolbarButton, type ToolbarButtonProps } from './Toolbar'; import { ToolbarButton } from './Toolbar';
/** /**
* Button to refresh the page if the content has been updated. * Button to refresh the page if the content has been updated.
*/ */
export function RefreshContentButton(props: { export function RefreshContentButton(props: { refreshForUpdates: () => Promise<void> }) {
refreshForUpdates: () => Promise<void>; const { refreshForUpdates } = props;
motionValues?: ToolbarButtonProps['motionValues'];
}) {
const { refreshForUpdates, motionValues } = props;
const [loading, setLoading] = React.useState(false); const [loading, setLoading] = React.useState(false);
@@ -34,7 +31,6 @@ export function RefreshContentButton(props: {
refresh(); refresh();
}} }}
disabled={loading} disabled={loading}
motionValues={motionValues}
icon="rotate" icon="rotate"
iconClassName={loading ? 'animate-spin' : undefined} iconClassName={loading ? 'animate-spin' : undefined}
/> />
@@ -1,12 +1,6 @@
'use client'; 'use client';
import { import { type MotionValue, motion, useReducedMotion, useSpring } from 'motion/react';
AnimatePresence, import React from 'react';
type MotionValue,
motion,
useReducedMotion,
useSpring,
} from 'motion/react';
import React, { isValidElement } from 'react';
import { Icon, type IconName, IconStyle } from '@gitbook/icons'; import { Icon, type IconName, IconStyle } from '@gitbook/icons';
@@ -15,7 +9,7 @@ import { AnimatedLogo } from './AnimatedLogo';
import { useToolbarControls } from './ToolbarControlsContext'; import { useToolbarControls } from './ToolbarControlsContext';
import { ToolbarVisibilityHint } from './ToolbarVisibilityHint'; import { ToolbarVisibilityHint } from './ToolbarVisibilityHint';
import { getCopyVariants, toolbarEasings } from './transitions'; import { getCopyVariants, toolbarEasings } from './transitions';
import { useMagnificationEffect } from './useMagnificationEffect'; import { TOOLTIP_OFFSET, useMagnificationEffect } from './useMagnificationEffect';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
const DURATION_LOGO_APPEARANCE = 2000; const DURATION_LOGO_APPEARANCE = 2000;
@@ -108,63 +102,93 @@ export function Toolbar(props: ToolbarProps) {
return ( return (
<motion.div <motion.div
data-testid="admin-toolbar" data-testid="admin-toolbar"
className="fixed bottom-5 left-1/2 z-40 w-auto max-w-xl -translate-x-1/2 transform px-4" // Centered with `inset-x-0 mx-auto`: at `left-1/2` it could only grow to half the viewport,
// which pushed the logo out of the pill.
className="fixed inset-x-0 bottom-5 z-40 mx-auto w-fit max-w-xl px-4"
> >
<ToolbarVisibilityHint show={showHint} /> <ToolbarVisibilityHint show={showHint} />
<AnimatePresence mode="wait"> <motion.div
onClick={() => {
if (minified) {
setShouldAnimateLogo(false);
onMinifiedChange(false);
}
}}
// Only minifying and expanding animate: content width changes (e.g. the date
// format) would otherwise run a bouncy scale animation that stretches the content.
layout
layoutDependency={minified}
onLayoutAnimationComplete={() => {
if (!minified) {
setShowHint(true);
}
}}
transition={toolbarEasings.spring}
className={tcls(
minified ? 'cursor-pointer' : 'pr-2 pl-3.5',
'relative',
'flex',
'items-center',
'justify-center',
'min-h-11',
'min-w-12',
'h-12',
'py-2',
'origin-center',
'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8',
'bg-[var(--toolbar-bg)]'
)}
style={PILL_STYLE}
>
{/* Logo — double-click to minimize */}
<motion.div <motion.div
onClick={() => {
if (minified) {
setShouldAnimateLogo(false);
onMinifiedChange(false);
}
}}
layout layout
onLayoutAnimationComplete={() => { layoutDependency={minified}
if (!minified) { onDoubleClick={(e) => {
setShowHint(true); if (minified) return;
} e.stopPropagation();
window.getSelection()?.removeAllRanges();
onMinifiedChange(true);
}} }}
transition={toolbarEasings.spring}
className={tcls(
minified ? 'cursor-pointer' : 'pr-2 pl-3.5',
'relative',
'flex',
'items-center',
'justify-center',
'min-h-11',
'min-w-12',
'h-12',
'py-2',
'origin-center',
'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8',
'bg-[var(--toolbar-bg)]'
)}
style={PILL_STYLE}
> >
{/* Logo — double-click to minimize */} <AnimatedLogo shouldAnimate={shouldAnimateLogo} />
</motion.div>
{/* A layout child, so it's counter-scaled while the pill expands. */}
{!minified ? (
<motion.div <motion.div
layout layout
onDoubleClick={(e) => { layoutDependency={minified}
if (minified) return; className="flex min-w-0 items-center"
e.stopPropagation();
window.getSelection()?.removeAllRanges();
onMinifiedChange(true);
}}
> >
<AnimatedLogo shouldAnimate={shouldAnimateLogo} /> {children}
</motion.div> </motion.div>
) : null}
{!minified ? children : null} </motion.div>
</motion.div>
</AnimatePresence>
</motion.div> </motion.div>
); );
} }
export function ToolbarBody(props: { children: React.ReactNode }) { export function ToolbarBody(props: { children: React.ReactNode }) {
return <div className="flex flex-col items-start px-3">{props.children}</div>; // Shrinks so a long title truncates, but keeps room for the longest date format.
return <div className="flex min-w-40 flex-col items-start px-3">{props.children}</div>;
}
const MAGNIFICATION_SPRING = { stiffness: 400, damping: 30 };
/** A button's magnification, and the unscaled box to place its tooltip against. */
type ToolbarSlot = {
scale: MotionValue<number>;
x: MotionValue<number>;
anchorRef: React.RefObject<HTMLDivElement | null>;
};
const ToolbarSlotContext = React.createContext<ToolbarSlot | null>(null);
/** The magnification of the toolbar button group slot this is rendered in, if any. */
export function useToolbarSlot() {
return React.useContext(ToolbarSlotContext);
} }
export function ToolbarButtonGroup(props: { children: React.ReactNode }) { export function ToolbarButtonGroup(props: { children: React.ReactNode }) {
@@ -172,8 +196,8 @@ export function ToolbarButtonGroup(props: { children: React.ReactNode }) {
const containerRef = React.useRef<HTMLDivElement>(null); const containerRef = React.useRef<HTMLDivElement>(null);
const buttonChildren = React.Children.toArray(children).filter((child) => !!child); const buttonChildren = React.Children.toArray(children).filter((child) => !!child);
const { buttonMotionValues } = useMagnificationEffect({ const { motionValues } = useMagnificationEffect({
childrenCount: buttonChildren.length, slotCount: buttonChildren.length,
containerRef, containerRef,
}); });
@@ -183,28 +207,51 @@ export function ToolbarButtonGroup(props: { children: React.ReactNode }) {
variants={toolbarEasings.parent} variants={toolbarEasings.parent}
initial="hidden" initial="hidden"
animate="show" animate="show"
className="flex items-center gap-1 overflow-visible pl-4 pr-2" // `relative`: the magnification measures slots by their `offsetLeft` in here.
className="relative flex items-center gap-1 overflow-visible pl-4 pr-2"
> >
{buttonChildren.map((child, index) => { {buttonChildren.map((child, index) => {
const childEl = child as React.ReactElement; const values = motionValues[index];
const childKey = childEl.key ?? `toolbar-button-${index}`; return values ? (
return ( <ToolbarSlot
<ToolbarButtonWrapper key={React.isValidElement(child) && child.key ? child.key : index}
key={childKey} scale={values.scale}
child={childEl} x={values.x}
rawMotionValues={buttonMotionValues[index]} >
/> {child}
); </ToolbarSlot>
) : null;
})} })}
</motion.div> </motion.div>
); );
} }
/**
* One button of a group: staggers in, and smooths its magnification for the button inside.
* Collapses when the button renders nothing, so it leaves no gap.
*/
function ToolbarSlot(props: {
scale: MotionValue<number>;
x: MotionValue<number>;
children: React.ReactNode;
}) {
const anchorRef = React.useRef<HTMLDivElement>(null);
const scale = useSpring(props.scale, MAGNIFICATION_SPRING);
const x = useSpring(props.x, MAGNIFICATION_SPRING);
const slot = React.useMemo(() => ({ scale, x, anchorRef }), [scale, x]);
return (
<motion.div
ref={anchorRef}
variants={toolbarEasings.staggeringChild}
className="relative empty:hidden"
>
<ToolbarSlotContext.Provider value={slot}>{props.children}</ToolbarSlotContext.Provider>
</motion.div>
);
}
export interface ToolbarButtonProps extends Omit<React.HTMLProps<HTMLAnchorElement>, 'title'> { export interface ToolbarButtonProps extends Omit<React.HTMLProps<HTMLAnchorElement>, 'title'> {
motionValues?: {
scale: MotionValue<number>;
x: MotionValue<number>;
};
icon: IconName; icon: IconName;
iconClassName?: string; iconClassName?: string;
title?: React.ReactNode; title?: React.ReactNode;
@@ -212,19 +259,10 @@ export interface ToolbarButtonProps extends Omit<React.HTMLProps<HTMLAnchorEleme
} }
export const ToolbarButton = React.forwardRef<HTMLDivElement, ToolbarButtonProps>((props, ref) => { export const ToolbarButton = React.forwardRef<HTMLDivElement, ToolbarButtonProps>((props, ref) => {
const { const { title, disabled, className, style, href, onClick, icon, iconClassName, children } =
title, props;
disabled,
motionValues,
className,
style,
href,
onClick,
icon,
iconClassName,
children,
} = props;
const reduceMotion = useReducedMotion(); const reduceMotion = useReducedMotion();
const slot = useToolbarSlot();
const anchor = ( const anchor = (
<motion.a <motion.a
@@ -233,23 +271,11 @@ export const ToolbarButton = React.forwardRef<HTMLDivElement, ToolbarButtonProps
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
style={ style={
reduceMotion reduceMotion || !slot
? undefined ? style
: { : { scale: slot.scale, x: slot.x, transformOrigin: 'bottom center', ...style }
scale: motionValues?.scale,
x: motionValues?.x,
transformOrigin: 'bottom center',
zIndex: motionValues?.scale ? 10 : 'auto',
...style,
}
} }
transition={{
type: 'spring',
stiffness: 400,
damping: 30,
}}
className={tcls( className={tcls(
'toolbar-button',
className, className,
'relative flex size-8 cursor-pointer items-center justify-center gap-1 truncate rounded-full text-sm transition-colors', 'relative flex size-8 cursor-pointer items-center justify-center gap-1 truncate rounded-full text-sm transition-colors',
'text-tint-7 hover:text-tint-1', 'text-tint-7 hover:text-tint-1',
@@ -262,75 +288,68 @@ export const ToolbarButton = React.forwardRef<HTMLDivElement, ToolbarButtonProps
<Icon <Icon
icon={icon} icon={icon}
iconStyle={IconStyle.Solid} iconStyle={IconStyle.Solid}
className={tcls('size-3.5 shrink-0 group-hover:scale-110', iconClassName)} className={tcls('size-3.5 shrink-0', iconClassName)}
/> />
</motion.a> </motion.a>
); );
return ( return (
<motion.div variants={toolbarEasings.staggeringChild} className="relative" ref={ref}> <div className="relative" ref={ref}>
{children ? children : null} {children ? children : null}
{title ? <Tooltip label={title}>{anchor}</Tooltip> : anchor} {/* Anchored to the unscaled slot with room for full magnification, so the gap doesn't
</motion.div> depend on how far the spring has grown when the tooltip opens. */}
{title ? (
<Tooltip
label={title}
anchor={slot?.anchorRef}
sideOffset={slot ? TOOLTIP_OFFSET : 4}
>
{anchor}
</Tooltip>
) : (
anchor
)}
</div>
); );
}); });
ToolbarButton.displayName = 'ToolbarButton'; ToolbarButton.displayName = 'ToolbarButton';
function ToolbarButtonWrapper(props: { export function ToolbarTitle(props: {
child: React.ReactElement; prefix?: string;
rawMotionValues?: { scale: MotionValue<number>; x: MotionValue<number> }; /** What a short prefix stands for, e.g. "Change request #54" for "#54". */
prefixLabel?: string;
suffix: string;
}) { }) {
const { child, rawMotionValues } = props; const { prefix, prefixLabel, suffix } = props;
// One tooltip for the whole title: the short prefix spelled out, and the full, often
// Convert the raw motion values to smooth spring easings // truncated, subject.
const springScale = useSpring(rawMotionValues?.scale.get() ?? 1, { const label = prefixLabel ? (
stiffness: 400, <span className="flex flex-col">
damping: 30, <span className="opacity-70 text-xxs">{prefixLabel}</span>
}); <span className="font-medium">{suffix}</span>
const springX = useSpring(rawMotionValues?.x.get() ?? 0, { stiffness: 400, damping: 30 }); </span>
) : prefix ? (
// Sync springs with raw motion values `${prefix}${suffix}`
React.useEffect(() => { ) : (
if (!rawMotionValues) return; suffix
);
const unsubScale = rawMotionValues.scale.on('change', (v) => springScale.set(v));
const unsubX = rawMotionValues.x.on('change', (v) => springX.set(v));
return () => {
unsubScale();
unsubX();
};
}, [rawMotionValues, springScale, springX]);
const motionValues = {
scale: springScale,
x: springX,
};
if (!isValidElement<{ motionValues: typeof motionValues }>(child)) {
return null;
}
return React.cloneElement(child, {
motionValues,
});
}
export function ToolbarTitle(props: { prefix?: string; suffix: string }) {
return ( return (
<div className="flex items-center gap-1 text-xs"> <Tooltip label={label}>
{props.prefix ? <ToolbarTitlePrefix title={props.prefix} /> : null} <div className="flex min-w-0 max-w-full items-center gap-1 text-xs">
<ToolbarTitleSuffix title={props.suffix} /> {prefix ? <ToolbarTitlePrefix title={prefix} /> : null}
</div> <ToolbarTitleSuffix title={suffix} />
</div>
</Tooltip>
); );
} }
function ToolbarTitlePrefix(props: { title: string }) { function ToolbarTitlePrefix(props: { title: string }) {
// Kept whole: prefixes are short, and the suffix is the one to truncate.
return ( return (
<motion.span <motion.span
{...getCopyVariants(0)} {...getCopyVariants(0)}
className="truncate font-medium text-neutral-1 dark:text-neutral-12" className="shrink-0 whitespace-nowrap font-medium text-neutral-1 dark:text-neutral-12"
> >
{props.title} {props.title}
</motion.span> </motion.span>
@@ -341,7 +360,7 @@ function ToolbarTitleSuffix(props: { title: string }) {
return ( return (
<motion.span <motion.span
{...getCopyVariants(1)} {...getCopyVariants(1)}
className="max-w-[20ch] truncate font-bold text-neutral-1 dark:text-neutral-12" className="min-w-0 max-w-[28ch] truncate font-bold text-neutral-1 dark:text-neutral-12"
> >
{props.title} {props.title}
</motion.span> </motion.span>
@@ -352,7 +371,7 @@ export function ToolbarSubtitle(props: { subtitle: React.ReactNode }) {
return ( return (
<motion.span <motion.span
{...getCopyVariants(1)} {...getCopyVariants(1)}
className="text-neutral-1/80 dark:text-neutral-12/80 inline-flex items-center gap-1 text-xxs" className="text-neutral-1/80 dark:text-neutral-12/80 flex min-w-0 max-w-full items-center gap-1 whitespace-nowrap text-xxs"
> >
{props.subtitle} {props.subtitle}
</motion.span> </motion.span>
@@ -35,7 +35,8 @@ export function ToolbarDate(props: { value: string }) {
return ( return (
<motion.div <motion.div
className="inline-flex items-center gap-1" // Never wraps: the longer formats push the author's name to truncate instead.
className="inline-flex shrink-0 items-center gap-1 whitespace-nowrap"
onMouseEnter={() => setHovered(true)} onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)} onMouseLeave={() => setHovered(false)}
> >
@@ -75,7 +76,7 @@ export function ToolbarDate(props: { value: string }) {
setFormatIndex((i) => (i + 1) % DATE_FORMATS.length); setFormatIndex((i) => (i + 1) % DATE_FORMATS.length);
}} }}
transition={{ type: 'spring', stiffness: 300, damping: 25 }} transition={{ type: 'spring', stiffness: 300, damping: 25 }}
className="relative inline-flex cursor-pointer select-none overflow-hidden font-semibold transition-colors hover:text-white" // `text-white` is used insead of dark-mode adapting values because the date is always in a dark-tinted toolbar className="relative inline-flex cursor-pointer select-none overflow-hidden whitespace-nowrap font-semibold transition-colors hover:text-white" // `text-white` is used insead of dark-mode adapting values because the date is always in a dark-tinted toolbar
> >
<AnimatePresence mode="popLayout" initial={false}> <AnimatePresence mode="popLayout" initial={false}>
<motion.span <motion.span
@@ -1,4 +1,4 @@
import { type MotionValue, motionValue } from 'motion/react'; import { type MotionValue, motionValue, useReducedMotion } from 'motion/react';
import React from 'react'; import React from 'react';
interface MagnificationConfig { interface MagnificationConfig {
@@ -16,40 +16,29 @@ interface MagnificationConfig {
padding?: number; padding?: number;
} }
export const BUTTON_SIZE = 32;
export const MAX_SCALE = 1.25;
/** Tooltip gap above a slot, with room for the button fully magnified (it grows from the bottom). */
export const TOOLTIP_OFFSET = 4 + BUTTON_SIZE * (MAX_SCALE - 1);
const defaultConfig: Required<MagnificationConfig> = { const defaultConfig: Required<MagnificationConfig> = {
buttonSize: 32, // Standard button size for 32px (size-8) buttons buttonSize: BUTTON_SIZE, // Standard button size for 32px (size-8) buttons
maxScale: 1.25, // 25% scale increase - noticeable but not overwhelming maxScale: MAX_SCALE, // 25% scale increase - noticeable but not overwhelming
influenceRadius: 80, // ~2.5 button widths of influence influenceRadius: 80, // ~2.5 button widths of influence
spacingMultiplier: 1.3, // Creates proportional spacing to scale increase spacingMultiplier: 1.3, // Creates proportional spacing to scale increase
scaleExponent: 2.5, // Exponential curve for dramatic close-range scaling scaleExponent: 2.5, // Exponential curve for dramatic close-range scaling
padding: 10, // Small buffer zone around container edges padding: 10, // Small buffer zone around container edges
}; };
const resetMotionValues = ( type SlotMotionValues = { scale: MotionValue<number>; x: MotionValue<number> };
motionValues: { scale: MotionValue<number>; x: MotionValue<number> }[]
) => { const resetMotionValues = (motionValues: SlotMotionValues[]) => {
motionValues.forEach(({ scale, x }) => { motionValues.forEach(({ scale, x }) => {
scale.set(1); scale.set(1);
x.set(0); x.set(0);
}); });
}; };
const captureButtonPositions = (buttons: HTMLElement[]) => {
// Reset transforms to get original positions
buttons.forEach((button) => {
button.style.transform = '';
});
return buttons.map((button) => {
const rect = button.getBoundingClientRect();
return {
left: rect.left,
width: rect.width,
top: rect.top,
height: rect.height,
};
});
};
const calculateScale = ( const calculateScale = (
mouseX: number, mouseX: number,
buttonCenterX: number, buttonCenterX: number,
@@ -62,8 +51,7 @@ const calculateScale = (
if (distance > config.influenceRadius) return 1; if (distance > config.influenceRadius) return 1;
// Calculate distance from container edge (X-axis only) // Calculate distance from container edge (X-axis only)
const distanceFromEdgeX = Math.min(mouseX - containerRect.left, containerRect.right - mouseX); const distanceFromEdge = Math.min(mouseX - containerRect.left, containerRect.right - mouseX);
const distanceFromEdge = distanceFromEdgeX;
// Define the "heart" zone - 8px from center (16x16px total area) // Define the "heart" zone - 8px from center (16x16px total area)
const heartZoneRadius = 8; const heartZoneRadius = 8;
@@ -94,24 +82,20 @@ const calculateScale = (
const calculateSpacing = ( const calculateSpacing = (
buttonIndex: number, buttonIndex: number,
buttonEffects: { scale: number; translateX: number }[], scales: number[],
positions: { left: number; width: number; top: number; height: number }[], centers: (number | null)[],
config: Required<MagnificationConfig> config: Required<MagnificationConfig>
) => { ) => {
const currentPos = positions[buttonIndex]; const buttonCenterX = centers[buttonIndex];
if (!currentPos) return 0; if (buttonCenterX == null) return 0;
const buttonCenterX = currentPos.left + currentPos.width / 2;
let leftPush = 0; let leftPush = 0;
let rightPush = 0; let rightPush = 0;
buttonEffects.forEach((otherEffect, otherIndex) => { scales.forEach((otherScale, otherIndex) => {
if (otherIndex === buttonIndex) return; const otherCenterX = centers[otherIndex];
if (otherIndex === buttonIndex || otherCenterX == null) return;
const otherPos = positions[otherIndex];
if (!otherPos) return;
const otherCenterX = otherPos.left + otherPos.width / 2;
const distanceBetween = Math.abs(buttonCenterX - otherCenterX); const distanceBetween = Math.abs(buttonCenterX - otherCenterX);
// Only apply influence if close enough // Only apply influence if close enough
@@ -120,7 +104,7 @@ const calculateSpacing = (
0, 0,
1 - distanceBetween / (config.influenceRadius * 1.5) 1 - distanceBetween / (config.influenceRadius * 1.5)
); );
const extraSpace = (otherEffect.scale - 1) * config.buttonSize; const extraSpace = (otherScale - 1) * config.buttonSize;
const pushAmount = (extraSpace / 2) * config.spacingMultiplier * influenceStrength; const pushAmount = (extraSpace / 2) * config.spacingMultiplier * influenceStrength;
if (otherCenterX < buttonCenterX) { if (otherCenterX < buttonCenterX) {
@@ -134,104 +118,88 @@ const calculateSpacing = (
return rightPush - leftPush; return rightPush - leftPush;
}; };
/**
* Dock-like magnification of the slots (direct children) of `containerRef`. Returns raw motion
* values per slot index, meant to be smoothed with springs by the slots.
*/
export function useMagnificationEffect(props: { export function useMagnificationEffect(props: {
childrenCount: number; slotCount: number;
containerRef: React.RefObject<HTMLElement | null>; containerRef: React.RefObject<HTMLElement | null>;
config?: MagnificationConfig; config?: MagnificationConfig;
}) { }) {
const { childrenCount, containerRef, config } = props; const { slotCount, containerRef, config } = props;
const originalPositionsRef = React.useRef< const reduceMotion = useReducedMotion();
{ left: number; width: number; top: number; height: number }[]
>([]);
const finalConfig = React.useMemo(() => ({ ...defaultConfig, ...config }), [config]); const finalConfig = React.useMemo(() => ({ ...defaultConfig, ...config }), [config]);
// Create basic motion values that will be consumed by springs in the components // Stable across renders, grown as slots are added, so existing springs keep their source.
const buttonMotionValues = React.useMemo(() => { const motionValuesRef = React.useRef<SlotMotionValues[]>([]);
return Array.from({ length: childrenCount }, () => ({ while (motionValuesRef.current.length < slotCount) {
scale: motionValue(1), motionValuesRef.current.push({ scale: motionValue(1), x: motionValue(0) });
x: motionValue(0), }
})); const motionValues = motionValuesRef.current;
}, [childrenCount]);
React.useEffect(() => { React.useEffect(() => {
const container = containerRef.current; const container = containerRef.current;
if (!container) return; if (!container || reduceMotion) {
resetMotionValues(motionValues);
const buttons = Array.from(container.querySelectorAll('.toolbar-button')) as HTMLElement[];
if (buttons.length !== childrenCount) {
console.error(
`Button count (${buttons.length}) does not match children count (${childrenCount})`
);
return; return;
} }
const handleMouseMove = (event: MouseEvent) => { let frame = 0;
const buttons = Array.from( let pointer = { x: 0, y: 0 };
container.querySelectorAll('.toolbar-button')
) as HTMLElement[];
// Capture positions if needed // Measured every frame from layout positions (`offsetLeft`), which the slots' transforms
if (originalPositionsRef.current.length !== buttons.length) { // don't affect, so nothing goes stale when the toolbar resizes, re-centers or animates in.
originalPositionsRef.current = captureButtonPositions(buttons); const update = () => {
} frame = 0;
const rect = container.getBoundingClientRect();
const containerRect = container.getBoundingClientRect(); const inRange =
const { clientX: mouseX, clientY: mouseY } = event; pointer.x >= rect.left - finalConfig.padding &&
pointer.x <= rect.right + finalConfig.padding &&
// Check if mouse is in range pointer.y >= rect.top - finalConfig.padding &&
const isInRange = pointer.y <= rect.bottom + finalConfig.padding;
mouseX >= containerRect.left - finalConfig.padding && if (!inRange) {
mouseX <= containerRect.right + finalConfig.padding && resetMotionValues(motionValues);
mouseY >= containerRect.top - finalConfig.padding &&
mouseY <= containerRect.bottom + finalConfig.padding;
if (!isInRange) {
resetMotionValues(buttonMotionValues);
return; return;
} }
// Calculate scales for all buttons const slots = Array.from(container.children) as HTMLElement[];
const buttonEffects = buttons.map((_, index) => { // Empty or hidden slots take no space and don't magnify.
const pos = originalPositionsRef.current[index]; const centers = slots.map((slot) =>
if (!pos) return { scale: 1, translateX: 0 }; slot.offsetWidth > 0 ? rect.left + slot.offsetLeft + slot.offsetWidth / 2 : null
);
const buttonCenterX = pos.left + pos.width / 2; const scales = centers.map((center) =>
const scale = calculateScale(mouseX, buttonCenterX, containerRect, finalConfig); center == null ? 1 : calculateScale(pointer.x, center, rect, finalConfig)
return { scale, translateX: 0 }; );
}); motionValues.forEach((values, index) => {
values.scale.set(scales[index] ?? 1);
// Calculate spacing for all buttons values.x.set(calculateSpacing(index, scales, centers, finalConfig));
buttonEffects.forEach((effect, index) => {
effect.translateX = calculateSpacing(
index,
buttonEffects,
originalPositionsRef.current,
finalConfig
);
});
// Update motion values
buttonEffects.forEach((effect, index) => {
const motionValue = buttonMotionValues[index];
if (motionValue) {
motionValue.scale.set(effect.scale);
motionValue.x.set(effect.translateX);
}
}); });
}; };
const handleMouseLeave = () => resetMotionValues(buttonMotionValues); const onPointerMove = (event: PointerEvent) => {
if (event.pointerType !== 'mouse') {
container.addEventListener('mousemove', handleMouseMove); return;
container.addEventListener('mouseleave', handleMouseLeave); }
pointer = { x: event.clientX, y: event.clientY };
if (!frame) {
frame = requestAnimationFrame(update);
}
};
const onPointerLeave = () => {
cancelAnimationFrame(frame);
frame = 0;
resetMotionValues(motionValues);
};
container.addEventListener('pointermove', onPointerMove);
container.addEventListener('pointerleave', onPointerLeave);
return () => { return () => {
container.removeEventListener('mousemove', handleMouseMove); cancelAnimationFrame(frame);
container.removeEventListener('mouseleave', handleMouseLeave); container.removeEventListener('pointermove', onPointerMove);
container.removeEventListener('pointerleave', onPointerLeave);
}; };
}, [finalConfig, containerRef, buttonMotionValues, childrenCount]); }, [containerRef, finalConfig, motionValues, reduceMotion]);
return { buttonMotionValues }; return { motionValues };
} }
@@ -9,7 +9,7 @@ import { Icon, type IconName } from '@gitbook/icons';
import { Link, type LinkInsightsProps } from '.'; import { Link, type LinkInsightsProps } from '.';
import { ToggleChevron } from './ToggleChevron'; import { ToggleChevron } from './ToggleChevron';
import { Tooltip } from './Tooltip'; import { Tooltip, type TooltipProps } from './Tooltip';
import { type ClassValue, tcls } from '@/lib/tailwind'; import { type ClassValue, tcls } from '@/lib/tailwind';
export type DropdownButtonProps<E extends HTMLElement = HTMLElement> = Omit< export type DropdownButtonProps<E extends HTMLElement = HTMLElement> = Omit<
@@ -41,6 +41,8 @@ export function DropdownMenu(props: {
button: React.ReactElement<Record<string, unknown>>; button: React.ReactElement<Record<string, unknown>>;
/** Tooltip label for the button */ /** Tooltip label for the button */
buttonTooltip?: React.ReactNode; buttonTooltip?: React.ReactNode;
/** Placement of the button's tooltip. */
buttonTooltipProps?: Pick<TooltipProps, 'side' | 'sideOffset' | 'anchor'>;
/** Content of the dropdown */ /** Content of the dropdown */
children: React.ReactNode; children: React.ReactNode;
/** Custom styles */ /** Custom styles */
@@ -72,6 +74,7 @@ export function DropdownMenu(props: {
const { const {
button, button,
buttonTooltip, buttonTooltip,
buttonTooltipProps,
children, children,
className, className,
openOnHover = false, openOnHover = false,
@@ -98,7 +101,7 @@ export function DropdownMenu(props: {
<DropdownMenuContext.Provider value={{ open, setOpen }}> <DropdownMenuContext.Provider value={{ open, setOpen }}>
<Menu.Root modal={false} open={open} onOpenChange={setOpen}> <Menu.Root modal={false} open={open} onOpenChange={setOpen}>
{buttonTooltip ? ( {buttonTooltip ? (
<Tooltip label={buttonTooltip} disableHoverablePopup> <Tooltip label={buttonTooltip} disableHoverablePopup {...buttonTooltipProps}>
{trigger} {trigger}
</Tooltip> </Tooltip>
) : ( ) : (
@@ -10,6 +10,8 @@ export type TooltipProps = {
side?: BaseTooltip.Positioner.Props['side']; side?: BaseTooltip.Positioner.Props['side'];
align?: BaseTooltip.Positioner.Props['align']; align?: BaseTooltip.Positioner.Props['align'];
sideOffset?: number; sideOffset?: number;
/** Positions against this element instead of the trigger, e.g. when the trigger is transformed. */
anchor?: BaseTooltip.Positioner.Props['anchor'];
delay?: number; delay?: number;
disabled?: boolean; disabled?: boolean;
disableHoverablePopup?: boolean; disableHoverablePopup?: boolean;
@@ -31,6 +33,7 @@ export function Tooltip(
side, side,
align, align,
sideOffset = 4, sideOffset = 4,
anchor,
delay, delay,
disabled, disabled,
disableHoverablePopup, disableHoverablePopup,
@@ -69,6 +72,7 @@ export function Tooltip(
side={side} side={side}
align={align} align={align}
sideOffset={sideOffset} sideOffset={sideOffset}
anchor={anchor}
collisionPadding={8} collisionPadding={8}
> >
<BaseTooltip.Popup <BaseTooltip.Popup