deepmoon

Toolbar

Focus a button and use ArrowLeft/ArrowRight (or Home/End) to move. Only one button is ever a Tab stop.

Installation

$ npx shadcn@latest add https://deepmoon.dev/r/toolbar.json

Props

ToolbarProps
PropTypeRequired
aria-labelstringYes
ToolbarButtonProps
PropTypeRequired
idstringYes

Code

toolbar.tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";

interface ToolbarContextValue {
  activeId: string | null;
  setActiveId: (id: string) => void;
}

const ToolbarContext = React.createContext<ToolbarContextValue | null>(null);

function useToolbarContext() {
  const context = React.useContext(ToolbarContext);
  if (!context) {
    throw new Error("<ToolbarButton> must be used within <Toolbar>.");
  }
  return context;
}

export interface ToolbarProps extends React.ComponentPropsWithoutRef<"div"> {
  "aria-label": string;
}

/**
 * role="toolbar" with roving tabindex: only one button is ever a Tab stop
 * (activeId); ArrowLeft/Right/Home/End move both focus and that stop
 * across every [data-toolbar-item] found via DOM query, so it composes
 * correctly with ToolbarGroup/ToolbarSeparator without needing them to
 * participate in any registration logic.
 */
export function Toolbar({ className, children, onKeyDown, ...props }: ToolbarProps) {
  const [activeId, setActiveId] = React.useState<string | null>(null);
  const rootRef = React.useRef<HTMLDivElement>(null);

  React.useEffect(() => {
    if (activeId !== null) return;
    const first = rootRef.current?.querySelector<HTMLElement>("[data-toolbar-item]:not(:disabled)");
    if (first?.dataset.toolbarItem) setActiveId(first.dataset.toolbarItem);
  }, [activeId]);

  return (
    <ToolbarContext.Provider value={{ activeId, setActiveId }}>
      <div
        ref={rootRef}
        role="toolbar"
        className={cn("flex items-center gap-1", className)}
        onKeyDown={(event) => {
          onKeyDown?.(event);
          if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
          const items = Array.from(
            rootRef.current?.querySelectorAll<HTMLButtonElement>("[data-toolbar-item]:not(:disabled)") ?? [],
          );
          if (items.length === 0) return;
          const currentIndex = items.indexOf(document.activeElement as HTMLButtonElement);
          let nextIndex = currentIndex;
          if (event.key === "ArrowRight") nextIndex = (currentIndex + 1) % items.length;
          else if (event.key === "ArrowLeft") nextIndex = (currentIndex - 1 + items.length) % items.length;
          else if (event.key === "Home") nextIndex = 0;
          else if (event.key === "End") nextIndex = items.length - 1;
          if (nextIndex === currentIndex) return;
          event.preventDefault();
          const next = items[nextIndex];
          next.focus();
          if (next.dataset.toolbarItem) setActiveId(next.dataset.toolbarItem);
        }}
        {...props}
      >
        {children}
      </div>
    </ToolbarContext.Provider>
  );
}

export function ToolbarGroup({ className, ...props }: React.ComponentPropsWithoutRef<"div">) {
  return <div role="group" className={cn("flex items-center gap-1", className)} {...props} />;
}

export function ToolbarSeparator({ className, ...props }: React.ComponentPropsWithoutRef<"div">) {
  return (
    <div
      role="separator"
      aria-orientation="vertical"
      className={cn("mx-1 h-5 w-px shrink-0 bg-border", className)}
      {...props}
    />
  );
}

export interface ToolbarButtonProps extends React.ComponentPropsWithoutRef<"button"> {
  /** Stable identity used to track which button is the current roving tab stop. */
  id: string;
}

export function ToolbarButton({ id, className, ...props }: ToolbarButtonProps) {
  const { activeId, setActiveId } = useToolbarContext();

  return (
    <button
      type="button"
      data-toolbar-item={id}
      tabIndex={activeId === id ? 0 : -1}
      onFocus={() => setActiveId(id)}
      className={cn(
        "flex size-8 items-center justify-center rounded-md text-foreground outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
        className,
      )}
      {...props}
    />
  );
}