Editor Toolbar
Navigation

Editor Toolbar

Rich-text toolbar with grouped toggle buttons and a priority-plus overflow that folds low-priority groups into a menu as space runs out.

Install

npx shadcn@latest add @paragon/editor-toolbar

Also installs: dropdown-menu, tooltip

editor-toolbar.tsx

"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>
  );
}