"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
export type FormatAlign = "left" | "center" | "right";
export type FormatBlock = "p" | "h1" | "h2" | "h3";
export interface FormatState {
bold: boolean;
italic: boolean;
underline: boolean;
strike: boolean;
align: FormatAlign;
block: FormatBlock;
}
export const defaultFormatState: FormatState = {
bold: false,
italic: false,
underline: false,
strike: false,
align: "left",
block: "p",
};
export interface FormattingToolbarProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "defaultValue" | "onChange"> {
/** Controlled formatting state. */
value?: FormatState;
/** Initial state when uncontrolled. */
defaultValue?: Partial<FormatState>;
/** Called with the next state on every change. */
onValueChange?: (value: FormatState) => void;
size?: "sm" | "md";
/** Show tooltips with the label and shortcut on hover and keyboard focus. */
tooltips?: boolean;
/** Hover delay before the first tooltip appears, in ms. Neighbours then open instantly. */
tooltipDelay?: number;
/** Enable Ctrl / ⌘ shortcuts (B, I, U, ⇧X, ⇧L / E / R, ⌥0–3) on the toolbar and `shortcutTarget`. */
shortcuts?: boolean;
/** Element whose key presses also trigger the shortcuts (e.g. the editor or preview). */
shortcutTarget?: React.RefObject<HTMLElement | null>;
/** Collapse trailing items into a "More" menu when the toolbar runs out of room. */
overflow?: boolean;
/** Pressed-state tint, sliding indicator and focus rings. */
accent?: string;
/** Accessible name of the toolbar. */
label?: string;
}
type ToggleKey = "bold" | "italic" | "underline" | "strike";
interface Keys {
shift?: boolean;
alt?: boolean;
/** KeyboardEvent.code, so shortcuts survive keyboard layouts and ⌥ on macOS. */
code: string;
/** Character shown in the hint. */
key: string;
}
type ToolItem =
| { kind: "block"; id: "block" }
| { kind: "separator"; id: string }
| { kind: "toggle"; id: ToggleKey; label: string; keys: Keys; icon: string }
| { kind: "align"; id: string; value: FormatAlign; label: string; keys: Keys; icon: string };
const ITEMS: ToolItem[] = [
{ kind: "block", id: "block" },
{ kind: "separator", id: "sep-1" },
{ kind: "toggle", id: "bold", label: "Bold", keys: { code: "KeyB", key: "B" }, icon: "M5 3.25h4a2.6 2.6 0 0 1 0 5.2H5zM5 8.45h4.75a2.65 2.65 0 0 1 0 5.3H5z" },
{ kind: "toggle", id: "italic", label: "Italic", keys: { code: "KeyI", key: "I" }, icon: "M7 3.25h5M4 12.75h5M9.6 3.25 6.4 12.75" },
{ kind: "toggle", id: "underline", label: "Underline", keys: { code: "KeyU", key: "U" }, icon: "M4.75 3v4.75a3.25 3.25 0 0 0 6.5 0V3M3.75 13.75h8.5" },
{
kind: "toggle",
id: "strike",
label: "Strikethrough",
keys: { shift: true, code: "KeyX", key: "X" },
icon: "M2.75 8h10.5M11 4.9C10.55 3.85 9.5 3.25 8 3.25c-1.8 0-3 .85-3 2.2 0 .95.6 1.6 1.7 2M5 11.1c.45 1.05 1.5 1.65 3 1.65 1.8 0 3-.85 3-2.2 0-.4-.1-.75-.3-1.05",
},
{ kind: "separator", id: "sep-2" },
{ kind: "align", id: "align-left", value: "left", label: "Align left", keys: { shift: true, code: "KeyL", key: "L" }, icon: "M2.75 4h10.5M2.75 8h6.5M2.75 12h8.5" },
{ kind: "align", id: "align-center", value: "center", label: "Align centre", keys: { shift: true, code: "KeyE", key: "E" }, icon: "M2.75 4h10.5M4.75 8h6.5M3.75 12h8.5" },
{ kind: "align", id: "align-right", value: "right", label: "Align right", keys: { shift: true, code: "KeyR", key: "R" }, icon: "M2.75 4h10.5M6.75 8h6.5M4.75 12h8.5" },
];
const BLOCKS: { value: FormatBlock; label: string; keys: Keys }[] = [
{ value: "p", label: "Paragraph", keys: { alt: true, code: "Digit0", key: "0" } },
{ value: "h1", label: "Heading 1", keys: { alt: true, code: "Digit1", key: "1" } },
{ value: "h2", label: "Heading 2", keys: { alt: true, code: "Digit2", key: "2" } },
{ value: "h3", label: "Heading 3", keys: { alt: true, code: "Digit3", key: "3" } },
];
const DIMENSIONS = {
sm: { button: 28, block: 112, text: "text-[12px]", icon: "size-3.5" },
md: { button: 32, block: 124, text: "text-[13px]", icon: "size-4" },
} as const;
const GAP = 2; // px between items (gap-0.5)
const SEPARATOR = 9; // 1px line + 4px margin each side
const CHROME = 10; // 4px padding + 1px border on each side
const SKIP_DELAY = 400; // ms after a tooltip hides during which the next one opens instantly
const EDGE = 8;
/* ------------------------------- environment ------------------------------- */
function subscribeReducedMotion(cb: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", cb);
return () => mq.removeEventListener("change", cb);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
function detectMac() {
const nav = navigator as Navigator & { userAgentData?: { platform?: string } };
return /mac|iphone|ipad|ipod/i.test(nav.userAgentData?.platform ?? nav.platform ?? "");
}
const noopSubscribe = () => () => {};
/** "Ctrl" on the server and first paint, refined to ⌘ on Apple platforms after hydration. */
function useIsMac() {
return React.useSyncExternalStore(noopSubscribe, detectMac, () => false);
}
function shortcutHint(keys: Keys, mac: boolean) {
if (mac) return `${keys.alt ? "⌥" : ""}${keys.shift ? "⇧" : ""}⌘${keys.key}`;
return `Ctrl+${keys.alt ? "Alt+" : ""}${keys.shift ? "Shift+" : ""}${keys.key}`;
}
function ariaShortcut(keys: Keys, mac: boolean) {
return [mac ? "Meta" : "Control", keys.alt ? "Alt" : "", keys.shift ? "Shift" : "", keys.key].filter(Boolean).join("+");
}
function spokenShortcut(keys: Keys, mac: boolean) {
return [mac ? "Command" : "Control", keys.alt ? (mac ? "Option" : "Alt") : "", keys.shift ? "Shift" : "", keys.key].filter(Boolean).join(" ");
}
function matches(event: KeyboardEvent | React.KeyboardEvent, keys: Keys, mac: boolean) {
const mod = mac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey;
return mod && event.code === keys.code && event.shiftKey === Boolean(keys.shift) && event.altKey === Boolean(keys.alt);
}
function Glyph({ d, className }: { d: string; className: string }) {
return (
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className={className}>
<path d={d} stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
/* --------------------------------- toolbar --------------------------------- */
/**
* An ARIA toolbar for text formatting: bold / italic / underline / strikethrough toggle buttons
* (aria-pressed), an alignment radio group, a block-type menu button and separators, with one tab
* stop and roving focus (Arrow keys, Home / End). Trailing items collapse into a "More" menu when
* the toolbar runs out of room, tooltips show the label and a platform-aware shortcut, and the
* Ctrl / ⌘ shortcuts can be scoped to a target element. It only manages state — style anything.
*/
export function FormattingToolbar({
value: valueProp,
defaultValue,
onValueChange,
size = "md",
tooltips = true,
tooltipDelay = 500,
shortcuts = true,
shortcutTarget,
overflow = true,
accent = "#FF7448",
label = "Text formatting",
className = "",
style,
...props
}: FormattingToolbarProps) {
const uid = React.useId();
const mac = useIsMac();
const reduceMotion = usePrefersReducedMotion();
const dims = DIMENSIONS[size] ?? DIMENSIONS.md;
const wrapRef = React.useRef<HTMLDivElement>(null);
const toolbarRef = React.useRef<HTMLDivElement>(null);
const [internal, setInternal] = React.useState<FormatState>(() => ({ ...defaultFormatState, ...defaultValue }));
const value = valueProp ?? internal;
const [visibleCount, setVisibleCount] = React.useState(ITEMS.length);
const [activeId, setActiveId] = React.useState<string>("block");
const [menu, setMenu] = React.useState<{ kind: "block" | "more"; anchor: HTMLElement; focus: "first" | "last" | "checked" | "panel" } | null>(null);
const [tip, setTip] = React.useState<{ id: string; text: string; hint?: string; left: number } | null>(null);
const tipTimer = React.useRef(0);
const tipOpen = React.useRef(false);
const tipHiddenAt = React.useRef(-Infinity);
const update = (patch: Partial<FormatState>) => {
const next = { ...value, ...patch };
if (valueProp === undefined) setInternal(next);
onValueChange?.(next);
};
/* -------------------------------- overflow -------------------------------- */
React.useEffect(() => {
const wrap = wrapRef.current;
if (!wrap || !overflow) return;
const widthOf = (item: ToolItem) => (item.kind === "separator" ? SEPARATOR : item.kind === "block" ? dims.block : dims.button);
const measure = () => {
const available = wrap.clientWidth;
const total = (count: number, more: boolean) => {
const shown = ITEMS.slice(0, count);
const n = shown.length + (more ? 1 : 0);
return CHROME + shown.reduce((sum, item) => sum + widthOf(item), 0) + (more ? dims.button : 0) + Math.max(0, n - 1) * GAP;
};
let count = ITEMS.length;
if (total(count, false) > available) {
count = ITEMS.length - 1;
while (count > 1 && total(count, true) > available) count--;
// Never end on a separator.
while (count > 1 && ITEMS[count - 1].kind === "separator") count--;
}
setVisibleCount(count);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(wrap);
return () => ro.disconnect();
}, [overflow, dims]);
const count = overflow ? visibleCount : ITEMS.length;
const visible = ITEMS.slice(0, count);
const hidden = ITEMS.slice(count).filter((item) => item.kind !== "separator");
const focusableIds = [...visible.filter((i) => i.kind !== "separator").map((i) => i.id), ...(hidden.length ? ["more"] : [])];
const tabStop = focusableIds.includes(activeId) ? activeId : focusableIds[0];
/* -------------------------------- shortcuts ------------------------------- */
/** Applies the shortcut matching `event`, if any. */
const runShortcut = (event: KeyboardEvent | React.KeyboardEvent) => {
const isMac = detectMac();
for (const item of ITEMS) {
if (item.kind === "toggle" && matches(event, item.keys, isMac)) {
event.preventDefault();
update({ [item.id]: !value[item.id] });
return true;
}
if (item.kind === "align" && matches(event, item.keys, isMac)) {
event.preventDefault();
update({ align: item.value });
return true;
}
}
for (const block of BLOCKS) {
if (matches(event, block.keys, isMac)) {
event.preventDefault();
update({ block: block.value });
return true;
}
}
return false;
};
// The native listener on the target calls the latest handler without re-subscribing.
const shortcutHandler = React.useRef(runShortcut);
React.useEffect(() => {
shortcutHandler.current = runShortcut;
});
React.useEffect(() => {
const target = shortcutTarget?.current;
if (!shortcuts || !target) return;
const onKey = (event: KeyboardEvent) => {
if (!event.defaultPrevented) shortcutHandler.current(event);
};
target.addEventListener("keydown", onKey);
return () => target.removeEventListener("keydown", onKey);
}, [shortcuts, shortcutTarget]);
/* --------------------------------- tooltips -------------------------------- */
const hideTip = React.useCallback(() => {
window.clearTimeout(tipTimer.current);
if (tipOpen.current) tipHiddenAt.current = performance.now();
tipOpen.current = false;
setTip(null);
}, []);
const showTip = (el: HTMLElement) => {
if (!tooltips || menu) return;
window.clearTimeout(tipTimer.current);
const wrap = wrapRef.current;
const id = el.dataset.tbId ?? "";
if (!wrap || (tipOpen.current && tip?.id === id)) return;
const place = () => {
const w = wrap.getBoundingClientRect();
const r = el.getBoundingClientRect();
tipOpen.current = true;
setTip({ id, text: el.dataset.tip ?? "", hint: el.dataset.hint || undefined, left: Math.round(r.left - w.left + r.width / 2) });
};
// Delay group: while a tooltip is open (or just closed), neighbours open instantly.
const warm = tipOpen.current || performance.now() - tipHiddenAt.current < SKIP_DELAY;
if (warm) place();
else tipTimer.current = window.setTimeout(place, tooltipDelay);
};
// Escape dismisses the tooltip without moving focus or the pointer (WCAG 1.4.13).
React.useEffect(() => {
if (!tip) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") hideTip();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [tip, hideTip]);
React.useEffect(() => () => window.clearTimeout(tipTimer.current), []);
/** Attributes shared by every roving item: tab stop and tooltip text (read by the delegated handlers). */
const itemProps = (id: string, text: string, hint?: string) => ({
"data-tb-item": "",
"data-tb-id": id,
"data-tip": text,
"data-hint": hint ?? "",
tabIndex: id === tabStop ? 0 : -1,
});
const itemFrom = (target: EventTarget | null) => (target instanceof Element ? target.closest<HTMLElement>("[data-tb-item]") : null);
const onToolbarKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (shortcuts && runShortcut(event)) return;
const items = Array.from(event.currentTarget.querySelectorAll<HTMLElement>("[data-tb-item]"));
const index = items.indexOf(document.activeElement as HTMLElement);
if (index < 0) return;
const focusAt = (i: number) => items[(i + items.length) % items.length]?.focus();
if (event.key === "ArrowRight") focusAt(index + 1);
else if (event.key === "ArrowLeft") focusAt(index - 1);
else if (event.key === "Home") focusAt(0);
else if (event.key === "End") focusAt(-1);
else return;
event.preventDefault();
};
const openMenu = (kind: "block" | "more", anchor: HTMLElement, focus: "first" | "last" | "checked" | "panel") => {
// The tooltip render is suppressed while a menu is open (see below).
setTip(null);
setMenu({ kind, anchor, focus });
};
const closeMenu = (returnFocus: boolean) => {
if (returnFocus) menu?.anchor.focus({ preventScroll: true });
setMenu(null);
};
const menuButtonKeys = (kind: "block" | "more") => (event: React.KeyboardEvent<HTMLButtonElement>) => {
if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") {
event.preventDefault();
event.stopPropagation();
openMenu(kind, event.currentTarget, kind === "block" ? "checked" : "first");
} else if (event.key === "ArrowUp") {
event.preventDefault();
event.stopPropagation();
openMenu(kind, event.currentTarget, "last");
}
};
const pressedClass =
"aria-pressed:bg-[color-mix(in_oklab,var(--ft-accent)_18%,transparent)] aria-pressed:text-zinc-950 aria-pressed:shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--ft-accent)_45%,transparent)] dark:aria-pressed:text-white";
const buttonClass = `relative inline-flex shrink-0 items-center justify-center rounded-lg text-zinc-600 outline-hidden transition-[background-color,color,box-shadow] duration-150 hover:bg-zinc-900/[0.05] hover:text-zinc-950 focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-solid focus-visible:outline-[var(--ft-accent)] dark:text-zinc-400 dark:hover:bg-white/[0.06] dark:hover:text-white`;
const sq = { width: dims.button, height: dims.button };
const blockLabel = BLOCKS.find((b) => b.value === value.block)?.label ?? "Paragraph";
const blockKeys = BLOCKS.find((b) => b.value === value.block)?.keys;
const renderItem = (item: ToolItem) => {
if (item.kind === "separator") {
return <div key={item.id} role="separator" aria-orientation="vertical" className="mx-1 h-[55%] w-px shrink-0 self-center bg-zinc-900/10 dark:bg-white/10" />;
}
if (item.kind === "block") {
return (
<button
key={item.id}
type="button"
aria-haspopup="menu"
aria-expanded={menu?.kind === "block"}
aria-controls={menu?.kind === "block" ? `${uid}-menu` : undefined}
aria-label={`Text style: ${blockLabel}`}
{...itemProps(item.id, "Text style", blockKeys ? shortcutHint(blockKeys, mac) : undefined)}
onKeyDown={menuButtonKeys("block")}
onClick={(event) => (menu?.kind === "block" ? closeMenu(false) : openMenu("block", event.currentTarget, "panel"))}
className={`${buttonClass} justify-between gap-1 px-2.5 font-medium tracking-tight text-zinc-800 dark:text-zinc-200 ${dims.text} ${menu?.kind === "block" ? "bg-zinc-900/[0.05] dark:bg-white/[0.06]" : ""}`}
style={{ width: dims.block, height: dims.button }}
>
<span className="truncate">{blockLabel}</span>
<Glyph d="M4.5 6.25 8 9.75l3.5-3.5" className={`${dims.icon} shrink-0 opacity-60`} />
</button>
);
}
const describedBy = `${uid}-${item.id}-keys`;
if (item.kind === "toggle") {
const pressed = value[item.id];
return (
<button
key={item.id}
type="button"
aria-label={item.label}
aria-pressed={pressed}
aria-keyshortcuts={shortcuts ? ariaShortcut(item.keys, mac) : undefined}
aria-describedby={shortcuts ? describedBy : undefined}
{...itemProps(item.id, item.label, shortcuts ? shortcutHint(item.keys, mac) : undefined)}
onClick={() => update({ [item.id]: !pressed })}
className={`${buttonClass} ${pressedClass}`}
style={sq}
>
<Glyph d={item.icon} className={dims.icon} />
{shortcuts ? (
<span id={describedBy} hidden>
{spokenShortcut(item.keys, mac)}
</span>
) : null}
</button>
);
}
const checked = value.align === item.value;
return (
<button
key={item.id}
type="button"
role="radio"
aria-checked={checked}
aria-label={item.label}
aria-keyshortcuts={shortcuts ? ariaShortcut(item.keys, mac) : undefined}
aria-describedby={shortcuts ? describedBy : undefined}
{...itemProps(item.id, item.label, shortcuts ? shortcutHint(item.keys, mac) : undefined)}
onClick={() => update({ align: item.value })}
onKeyDown={(event) => {
if (event.key === " " || event.key === "Enter") {
event.preventDefault();
update({ align: item.value });
}
}}
className={`${buttonClass} ${checked ? "text-zinc-950 dark:text-white" : ""}`}
style={sq}
>
{checked ? (
<motion.span
layoutId={`${uid}-align`}
aria-hidden="true"
className="absolute inset-0 rounded-lg bg-[color-mix(in_oklab,var(--ft-accent)_18%,transparent)] shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--ft-accent)_45%,transparent)]"
transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 520, damping: 38 }}
/>
) : null}
<Glyph d={item.icon} className={`relative ${dims.icon}`} />
{shortcuts ? (
<span id={describedBy} hidden>
{spokenShortcut(item.keys, mac)}
</span>
) : null}
</button>
);
};
// Group the alignment radios so they are announced as one radio group.
const rendered: React.ReactNode[] = [];
let radios: React.ReactNode[] = [];
const flushRadios = () => {
if (!radios.length) return;
rendered.push(
<div key="align-group" role="radiogroup" aria-label="Alignment" className="flex shrink-0 items-center gap-0.5">
{radios}
</div>,
);
radios = [];
};
for (const item of visible) {
if (item.kind === "align") radios.push(renderItem(item));
else {
flushRadios();
if (!(item.kind === "separator" && item === visible[visible.length - 1])) rendered.push(renderItem(item));
}
}
flushRadios();
/* ---------------------------------- menus ---------------------------------- */
const menuEntries: MenuEntry[] =
menu?.kind === "block"
? BLOCKS.map((b) => ({
id: b.value,
role: "menuitemradio" as const,
label: b.label,
checked: value.block === b.value,
hint: shortcuts ? shortcutHint(b.keys, mac) : undefined,
preview: b.value,
keepOpen: false,
onSelect: () => update({ block: b.value }),
}))
: hidden.flatMap((item): MenuEntry[] => {
if (item.kind === "toggle") {
return [
{
id: item.id,
role: "menuitemcheckbox",
label: item.label,
checked: value[item.id],
icon: item.icon,
hint: shortcuts ? shortcutHint(item.keys, mac) : undefined,
keepOpen: true,
onSelect: () => update({ [item.id]: !value[item.id] }),
},
];
}
if (item.kind === "align") {
return [
{
id: item.id,
role: "menuitemradio",
label: item.label,
checked: value.align === item.value,
icon: item.icon,
hint: shortcuts ? shortcutHint(item.keys, mac) : undefined,
keepOpen: true,
onSelect: () => update({ align: item.value }),
},
];
}
if (item.kind === "block") {
return BLOCKS.map((b) => ({
id: b.value,
role: "menuitemradio" as const,
label: b.label,
checked: value.block === b.value,
hint: shortcuts ? shortcutHint(b.keys, mac) : undefined,
keepOpen: true,
onSelect: () => update({ block: b.value }),
}));
}
return [];
});
return (
<div
ref={wrapRef}
className={`relative flex w-full min-w-0 ${className}`}
style={{ ["--ft-accent" as string]: accent, ...style }}
{...props}
>
<div
ref={toolbarRef}
role="toolbar"
aria-label={label}
aria-orientation="horizontal"
onKeyDown={onToolbarKeyDown}
onFocus={(event) => {
const item = itemFrom(event.target);
if (!item) return;
setActiveId(item.dataset.tbId ?? "");
if (item === event.target && item.matches(":focus-visible")) showTip(item);
}}
onBlur={hideTip}
onPointerOver={(event) => {
const item = itemFrom(event.target);
if (event.pointerType === "mouse" && item) showTip(item);
}}
onPointerOut={(event) => {
const item = itemFrom(event.target);
if (event.pointerType === "mouse" && item && !item.contains(event.relatedTarget as Node | null)) hideTip();
}}
onPointerDown={hideTip}
className={`inline-flex max-w-full shrink-0 items-center gap-0.5 rounded-xl border border-zinc-900/10 bg-white p-1 shadow-[0_1px_2px_rgb(0_0_0/0.05),0_10px_24px_-14px_rgb(0_0_0/0.25)] dark:border-white/10 dark:bg-[#1a1d21] dark:shadow-[0_12px_28px_-14px_rgb(0_0_0/0.8)] ${overflow ? "" : "flex-wrap"}`}
>
{rendered}
{hidden.length ? (
<button
type="button"
aria-label="More formatting"
aria-haspopup="menu"
aria-expanded={menu?.kind === "more"}
aria-controls={menu?.kind === "more" ? `${uid}-menu` : undefined}
{...itemProps("more", `More · ${hidden.length}`)}
onKeyDown={menuButtonKeys("more")}
onClick={(event) => (menu?.kind === "more" ? closeMenu(false) : openMenu("more", event.currentTarget, "panel"))}
className={`${buttonClass} ${menu?.kind === "more" ? "bg-zinc-900/[0.05] dark:bg-white/[0.06]" : ""}`}
style={sq}
>
<svg viewBox="0 0 16 16" aria-hidden="true" className={dims.icon} fill="currentColor">
<circle cx="3.5" cy="8" r="1.25" />
<circle cx="8" cy="8" r="1.25" />
<circle cx="12.5" cy="8" r="1.25" />
</svg>
{hidden.some((item) => (item.kind === "toggle" && value[item.id]) || (item.kind === "align" && value.align === item.value && item.value !== "left")) ? (
<span aria-hidden="true" className="absolute right-1 top-1 size-1.5 rounded-full bg-[var(--ft-accent)]" />
) : null}
</button>
) : null}
</div>
<AnimatePresence>
{tip && tooltips && !menu ? (
<motion.div
key="tip"
aria-hidden="true"
className="pointer-events-none absolute top-full z-20 mt-2 flex -translate-x-1/2 items-center gap-2 whitespace-nowrap rounded-md bg-zinc-900 px-2 py-1 text-[12px] font-medium tracking-tight text-white shadow-lg dark:bg-zinc-100 dark:text-zinc-900"
style={{ left: tip.left }}
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -3 }}
animate={{ opacity: 1, y: 0, left: tip.left }}
exit={{ opacity: 0, transition: { duration: 0.08 } }}
transition={reduceMotion ? { duration: 0.1 } : { type: "spring", stiffness: 600, damping: 40, opacity: { duration: 0.12 } }}
>
{tip.text}
{tip.hint ? <kbd className="font-mono text-[10px] tracking-normal text-white/55 dark:text-zinc-900/55">{tip.hint}</kbd> : null}
</motion.div>
) : null}
</AnimatePresence>
<AnimatePresence>
{menu ? (
<ToolbarMenu
key={menu.kind}
id={`${uid}-menu`}
label={menu.kind === "block" ? "Text style" : "More formatting"}
anchor={menu.anchor}
focus={menu.focus}
entries={menuEntries}
reduceMotion={reduceMotion}
accent={accent}
onClose={closeMenu}
/>
) : null}
</AnimatePresence>
</div>
);
}
/* ---------------------------------- menu ---------------------------------- */
interface MenuEntry {
id: string;
role: "menuitemcheckbox" | "menuitemradio";
label: string;
checked: boolean;
icon?: string;
hint?: string;
/** Block previews render the label at that heading size. */
preview?: FormatBlock;
/** Toggles in the overflow menu keep it open so several can be changed. */
keepOpen: boolean;
onSelect: () => void;
}
const PREVIEW_CLASS: Record<FormatBlock, string> = {
p: "text-[13px] font-normal",
h1: "text-[17px] font-semibold tracking-[-0.02em]",
h2: "text-[15px] font-semibold tracking-[-0.01em]",
h3: "text-[13px] font-semibold",
};
function ToolbarMenu({
id,
label,
anchor,
focus,
entries,
reduceMotion,
accent,
onClose,
}: {
id: string;
label: string;
anchor: HTMLElement;
focus: "first" | "last" | "checked" | "panel";
entries: MenuEntry[];
reduceMotion: boolean;
accent: string;
onClose: (returnFocus: boolean) => void;
}) {
const nodeRef = React.useRef<HTMLDivElement | null>(null);
const typeahead = React.useRef({ buffer: "", timer: 0 });
const place = React.useCallback(() => {
const node = nodeRef.current;
if (!node) return;
const r = anchor.getBoundingClientRect();
const vw = document.documentElement.clientWidth;
const vh = window.innerHeight;
const w = node.offsetWidth;
const h = node.offsetHeight;
const above = r.bottom + 6 + h > vh - EDGE && r.top - 6 - h >= EDGE;
const left = Math.max(EDGE, Math.min(r.left, vw - w - EDGE));
node.style.left = `${Math.round(left)}px`;
node.style.top = `${Math.round(above ? r.top - 6 - h : r.bottom + 6)}px`;
node.style.transformOrigin = `${Math.round(r.left + r.width / 2 - left)}px ${above ? "100%" : "0%"}`;
}, [anchor]);
const mount = (node: HTMLDivElement | null) => {
if (!node || node === nodeRef.current) return;
nodeRef.current = node;
if (typeof node.showPopover === "function" && !node.matches(":popover-open")) {
try {
node.showPopover();
} catch {
// position: fixed fallback
}
}
place();
const items = Array.from(node.querySelectorAll<HTMLElement>("[data-tm-item]"));
const target =
focus === "first" ? items[0] : focus === "last" ? items[items.length - 1] : focus === "checked" ? (items.find((el) => el.getAttribute("aria-checked") === "true") ?? items[0]) : node;
(target ?? node).focus({ preventScroll: true });
};
React.useEffect(() => {
let frame = 0;
const onPointer = (event: PointerEvent) => {
const t = event.target as Node;
if (nodeRef.current?.contains(t) || anchor.contains(t)) return;
onClose(false);
};
const onMove = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(place);
};
const onBlur = () => onClose(false);
const t = typeahead.current;
document.addEventListener("pointerdown", onPointer, true);
window.addEventListener("scroll", onMove, { capture: true, passive: true });
window.addEventListener("resize", onMove);
window.addEventListener("blur", onBlur);
return () => {
cancelAnimationFrame(frame);
window.clearTimeout(t.timer);
document.removeEventListener("pointerdown", onPointer, true);
window.removeEventListener("scroll", onMove, { capture: true });
window.removeEventListener("resize", onMove);
window.removeEventListener("blur", onBlur);
};
}, [anchor, onClose, place]);
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
// Keys handled here never reach the toolbar's roving focus.
event.stopPropagation();
const items = Array.from(event.currentTarget.querySelectorAll<HTMLElement>("[data-tm-item]"));
const index = items.indexOf(document.activeElement as HTMLElement);
const focusAt = (i: number) => items[(i + items.length) % items.length]?.focus({ preventScroll: true });
switch (event.key) {
case "ArrowDown":
event.preventDefault();
focusAt(index + 1);
return;
case "ArrowUp":
event.preventDefault();
focusAt(index < 0 ? -1 : index - 1);
return;
case "Home":
event.preventDefault();
focusAt(0);
return;
case "End":
event.preventDefault();
focusAt(-1);
return;
case "Escape":
case "Tab":
event.preventDefault();
onClose(true);
return;
case "Enter":
case " ":
if (index >= 0) {
event.preventDefault();
items[index].click();
}
return;
}
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
const t = typeahead.current;
window.clearTimeout(t.timer);
t.buffer += event.key.toLowerCase();
t.timer = window.setTimeout(() => (t.buffer = ""), 600);
const ordered = [...items.slice(index + 1), ...items.slice(0, index + 1)];
ordered.find((el) => (el.dataset.label ?? "").startsWith(t.buffer))?.focus({ preventScroll: true });
}
};
const motionProps = reduceMotion
? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: { duration: 0.08 } }
: {
initial: { opacity: 0, scale: 0.94 },
animate: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.97, transition: { duration: 0.1 } },
transition: { type: "spring" as const, stiffness: 560, damping: 34, mass: 0.7 },
};
return (
<motion.div
ref={mount}
id={id}
role="menu"
aria-label={label}
tabIndex={-1}
popover="manual"
onKeyDown={onKeyDown}
{...motionProps}
className="fixed inset-auto m-0 min-w-[208px] rounded-xl border border-zinc-900/10 bg-white p-1 text-[13px] text-zinc-800 shadow-[0_24px_48px_-20px_rgb(0_0_0/0.4),0_2px_6px_-2px_rgb(0_0_0/0.08)] outline-hidden dark:border-white/10 dark:bg-[#1a1d21] dark:text-zinc-200 dark:shadow-[0_24px_48px_-16px_rgb(0_0_0/0.75)]"
style={{ ["--ft-accent" as string]: accent }}
>
{entries.map((entry) => (
<div
key={entry.id}
role={entry.role}
aria-checked={entry.checked}
tabIndex={-1}
data-tm-item=""
data-label={entry.label.toLowerCase()}
className="group/item flex min-h-8 cursor-default items-center gap-2 rounded-md px-2 py-1 outline-hidden transition-colors duration-75 focus:bg-[color-mix(in_oklab,var(--ft-accent)_14%,transparent)] focus:text-zinc-950 dark:focus:text-white"
onClick={() => {
entry.onSelect();
if (!entry.keepOpen) onClose(true);
}}
onPointerMove={(event) => {
if (event.pointerType === "mouse" && document.activeElement !== event.currentTarget) event.currentTarget.focus({ preventScroll: true });
}}
>
<span aria-hidden="true" className="flex w-4 shrink-0 items-center justify-center">
{entry.checked ? (
entry.role === "menuitemradio" ? (
<span className="size-1.5 rounded-full bg-[var(--ft-accent)] ring-[3px] ring-[color-mix(in_oklab,var(--ft-accent)_25%,transparent)]" />
) : (
<svg viewBox="0 0 16 16" fill="none" className="size-4 text-[var(--ft-accent)]">
<path d="M3.5 8.5 6.5 11.5 12.5 4.5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
) : null}
</span>
{entry.icon ? <Glyph d={entry.icon} className="size-4 shrink-0 text-zinc-500 dark:text-zinc-400" /> : null}
<span className={`min-w-0 flex-1 truncate ${entry.preview ? PREVIEW_CLASS[entry.preview] : ""}`}>{entry.label}</span>
{entry.hint ? (
<kbd aria-hidden="true" className="ml-4 shrink-0 font-mono text-[10px] tracking-normal text-zinc-400 dark:text-zinc-500">
{entry.hint}
</kbd>
) : null}
</div>
))}
</motion.div>
);
}