Rich-text toolbar with grouped toggle buttons and a priority-plus overflow that folds low-priority groups into a menu as space runs out.
npx shadcn@latest add @paragon/editor-toolbarAlso installs: dropdown-menu, tooltip
"use client";
import * as React from "react";
import { Ellipsis } from "lucide-react";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/registry/paragon/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/registry/paragon/ui/tooltip";
import { cn } from "@/lib/utils";
export interface EditorToolbarItem {
id: string;
label: string;
icon: React.ReactNode;
/** Display shortcut, e.g. "⌘B". */
shortcut?: string;
/** Toggle state. Leave undefined for momentary actions like Undo. */
active?: boolean;
disabled?: boolean;
}
export interface EditorToolbarGroup {
id: string;
/** Section label used in the overflow menu. */
label: string;
/** Lower collapses last; defaults to position. */
priority?: number;
items: EditorToolbarItem[];
}
export interface EditorToolbarProps
extends Omit<React.ComponentProps<"div">, "onSelect"> {
groups: EditorToolbarGroup[];
onItemSelect?: (itemId: string) => void;
}
function toolbarButtonClasses(active: boolean | undefined, disabled?: boolean) {
return cn(
"flex size-8 shrink-0 items-center justify-center rounded-md outline-none select-none",
"transition-[background-color,color,scale] duration-150 ease-out active:not-disabled:scale-[0.97]",
"focus-visible:ring-2 focus-visible:ring-ring",
"[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
active
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
disabled && "pointer-events-none opacity-40",
);
}
/**
* Persistent rich-text toolbar with a priority-plus overflow: an invisible
* clone renders every group so real widths are known, a ResizeObserver
* watches the bar, and the lowest-priority groups fold into a "…" menu the
* moment they stop fitting — measured in a layout effect so the first paint
* is already correct. One tab stop; arrows rove; toggles carry aria-pressed.
*/
export function EditorToolbar({
groups,
onItemSelect,
className,
...props
}: EditorToolbarProps) {
const containerRef = React.useRef<HTMLDivElement>(null);
const measureRef = React.useRef<HTMLDivElement>(null);
const widthsRef = React.useRef<Map<string, number>>(new Map());
const overflowWidthRef = React.useRef(40);
const [visibleIds, setVisibleIds] = React.useState<string[]>(() =>
groups.map((group) => group.id),
);
const [focusId, setFocusId] = React.useState<string | null>(null);
const groupsKey = groups.map((group) => group.id).join(" ");
const compute = React.useCallback(
(containerWidth: number) => {
const widths = widthsRef.current;
const total = groups.reduce(
(sum, group) => sum + (widths.get(group.id) ?? 0),
0,
);
let next: string[];
if (total <= containerWidth) {
next = groups.map((group) => group.id);
} else {
const available = containerWidth - overflowWidthRef.current;
const byPriority = groups
.map((group, index) => ({
id: group.id,
priority: group.priority ?? index,
index,
}))
.sort((a, b) => a.priority - b.priority || a.index - b.index);
const keep = new Set<string>();
let used = 0;
for (const entry of byPriority) {
const width = widths.get(entry.id) ?? 0;
if (used + width > available) break;
used += width;
keep.add(entry.id);
}
next = groups
.map((group) => group.id)
.filter((id) => keep.has(id));
}
setVisibleIds((prev) =>
prev.length === next.length && prev.every((id, i) => id === next[i])
? prev
: next,
);
},
[groups],
);
// Measure the clone before paint so the first frame never flashes.
React.useLayoutEffect(() => {
const measure = measureRef.current;
const container = containerRef.current;
if (!measure || !container) return;
const widths = new Map<string, number>();
for (const cell of measure.querySelectorAll<HTMLElement>(
"[data-measure-group]",
)) {
widths.set(cell.dataset.measureGroup ?? "", cell.offsetWidth);
}
widthsRef.current = widths;
const overflowCell = measure.querySelector<HTMLElement>(
"[data-measure-overflow]",
);
if (overflowCell) overflowWidthRef.current = overflowCell.offsetWidth;
compute(container.clientWidth);
}, [compute, groupsKey]);
React.useEffect(() => {
const container = containerRef.current;
if (!container) return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) compute(entry.contentRect.width);
});
observer.observe(container);
return () => observer.disconnect();
}, [compute]);
const visibleSet = new Set(visibleIds);
const visibleGroups = groups.filter((group) => visibleSet.has(group.id));
const hiddenGroups = groups.filter((group) => !visibleSet.has(group.id));
const overflowing = hiddenGroups.length > 0;
// Roving tabindex: one tab stop for the whole toolbar.
const focusableIds = [
...visibleGroups.flatMap((group) =>
group.items.filter((item) => !item.disabled).map((item) => item.id),
),
...(overflowing ? ["…overflow"] : []),
];
const tabTarget =
focusId && focusableIds.includes(focusId) ? focusId : focusableIds[0];
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (!["ArrowRight", "ArrowLeft", "Home", "End"].includes(event.key)) return;
const container = containerRef.current;
if (!container) return;
const buttons = Array.from(
container.querySelectorAll<HTMLButtonElement>(
"[data-toolbar-button]:not(:disabled)",
),
);
if (buttons.length === 0) return;
const current = buttons.indexOf(document.activeElement as HTMLButtonElement);
let next = 0;
if (event.key === "ArrowRight") next = (current + 1) % buttons.length;
if (event.key === "ArrowLeft")
next = (current - 1 + buttons.length) % buttons.length;
if (event.key === "End") next = buttons.length - 1;
buttons[next]?.focus();
event.preventDefault();
};
const renderGroup = (group: EditorToolbarGroup, interactive: boolean) => (
<div
key={group.id}
data-measure-group={interactive ? undefined : group.id}
className="flex items-center gap-0.5 pr-1.5 [&:not(:first-child)]:border-l [&:not(:first-child)]:pl-1.5"
>
{group.items.map((item) => {
const button = (
<button
key={item.id}
type="button"
data-toolbar-button={interactive ? "" : undefined}
tabIndex={interactive ? (item.id === tabTarget ? 0 : -1) : -1}
aria-label={item.label}
aria-pressed={item.active}
aria-keyshortcuts={item.shortcut}
disabled={item.disabled}
onFocus={interactive ? () => setFocusId(item.id) : undefined}
onClick={interactive ? () => onItemSelect?.(item.id) : undefined}
className={toolbarButtonClasses(item.active, item.disabled)}
>
{item.icon}
</button>
);
if (!interactive) return button;
return (
<Tooltip key={item.id}>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent className="flex items-center gap-2">
{item.label}
{item.shortcut && (
<span className="text-primary-foreground/60">
{item.shortcut}
</span>
)}
</TooltipContent>
</Tooltip>
);
})}
</div>
);
return (
<TooltipProvider>
<div
ref={containerRef}
role="toolbar"
aria-orientation="horizontal"
data-slot="editor-toolbar"
onKeyDown={onKeyDown}
className={cn(
"relative flex w-full min-w-0 items-center overflow-hidden rounded-lg bg-card p-1 shadow-border",
className,
)}
{...props}
>
{visibleGroups.map((group) => renderGroup(group, true))}
{overflowing && (
<div className="ml-auto flex items-center border-l pl-1.5">
<DropdownMenu>
<DropdownMenuTrigger
data-toolbar-button=""
tabIndex={"…overflow" === tabTarget ? 0 : -1}
onFocus={() => setFocusId("…overflow")}
aria-label="More tools"
className={cn(
toolbarButtonClasses(undefined),
"data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
)}
>
<Ellipsis />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-48">
{hiddenGroups.map((group, groupIndex) => (
<React.Fragment key={group.id}>
{groupIndex > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel>{group.label}</DropdownMenuLabel>
{group.items.map((item) =>
item.active === undefined ? (
<DropdownMenuItem
key={item.id}
disabled={item.disabled}
onSelect={() => onItemSelect?.(item.id)}
>
{item.icon}
{item.label}
{item.shortcut && (
<DropdownMenuShortcut>
{item.shortcut}
</DropdownMenuShortcut>
)}
</DropdownMenuItem>
) : (
<DropdownMenuCheckboxItem
key={item.id}
checked={item.active}
disabled={item.disabled}
onSelect={(event) => {
event.preventDefault();
onItemSelect?.(item.id);
}}
>
{item.label}
{item.shortcut && (
<DropdownMenuShortcut>
{item.shortcut}
</DropdownMenuShortcut>
)}
</DropdownMenuCheckboxItem>
),
)}
</React.Fragment>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{/* Invisible measuring clone — every group at natural width. */}
<div
ref={measureRef}
aria-hidden
className="pointer-events-none invisible absolute top-0 left-0 flex h-0 items-center overflow-hidden"
>
{groups.map((group) => renderGroup(group, false))}
<div data-measure-overflow className="flex items-center border-l pl-1.5">
<span className={toolbarButtonClasses(undefined)}>
<Ellipsis className="size-4" />
</span>
</div>
</div>
</div>
</TooltipProvider>
);
}