Vela UI Docsvenizia.ai

Doc Slash Menu

The grouped, filterable block picker that a slash opens in the document editor.

npx shadcn@latest add @velaui/doc-slash-menu
npx shadcn@latest add @velaui/doc-slash-menu

Examples

  • Default
  • Typing slash
  • Filtered

View code

Doc Slash Menu · Default

Installation

pnpm dlx velaui add @velaui/doc-slash-menu
pnpm dlx velaui add @velaui/doc-slash-menu

Code

doc-slash-menu-default.tsx
import * as React from "react";

import { DocSlashMenu } from "@/components/ui/doc-slash-menu";

export default function Example() {
  const [picked, setPicked] = React.useState("");
  return (
    <div className="w-full max-w-xl space-y-2 p-6">
      <DocSlashMenu onPick={setPicked} />
      <p className="text-sm text-text-muted">{picked ? `Picked: ${picked}` : "Pick a block"}</p>
    </div>
  );
}

View code

Doc Slash Menu · Typing slash

Installation

pnpm dlx velaui add @velaui/doc-slash-menu
pnpm dlx velaui add @velaui/doc-slash-menu

Code

doc-slash-menu-typing-slash.tsx
import * as React from "react";

import { DocSlashMenu, DOC_SLASH_ITEMS } from "@/components/ui/doc-slash-menu";
import { DocTypography, type DocTypographyType } from "@/components/ui/doc-typography";

const HEADINGS: DocTypographyType[] = ["h1", "h2", "h3", "quote"];

export default function Example() {
  const [text, setText] = React.useState("");
  const [kind, setKind] = React.useState("text");
  // The menu is open while the line starts with "/"; what follows the slash filters it.
  const open = text.startsWith("/");
  const label = DOC_SLASH_ITEMS.find((i) => i.id === kind)?.label;

  return (
    <div className="relative w-full max-w-xl p-6">
      <DocTypography
        type={HEADINGS.includes(kind as DocTypographyType) ? (kind as DocTypographyType) : "text"}
        value={text}
        placeholder="Type / for blocks"
        onChange={setText}
        onKeyDown={(e) => {
          if (e.key === "Escape") setText("");
          if (open && e.key === "Enter") e.preventDefault();
        }}
      />
      {open && (
        <DocSlashMenu
          className="absolute left-6 z-10 mt-1"
          query={text.slice(1)}
          onClose={() => setText("")}
          onPick={(id) => {
            setKind(id);
            setText("");
          }}
        />
      )}
      <p className="mt-2 text-xs text-text-muted">Block type: {label}</p>
    </div>
  );
}

View code

Doc Slash Menu · Filtered

Installation

pnpm dlx velaui add @velaui/doc-slash-menu
pnpm dlx velaui add @velaui/doc-slash-menu

Code

doc-slash-menu-filtered.tsx
import { DocSlashMenu } from "@/components/ui/doc-slash-menu";

export default function Example() {
  return (
    <div className="w-full max-w-xl p-6">
      <DocSlashMenu query="head" onPick={() => {}} />
    </div>
  );
}

Usage

import * as React from "react";

import { DocSlashMenu } from "@/components/ui/doc-slash-menu";

export default function Example() {
  const [picked, setPicked] = React.useState("");
  return (
    <div className="w-full max-w-xl space-y-2 p-6">
      <DocSlashMenu onPick={setPicked} />
      <p className="text-sm text-text-muted">{picked ? `Picked: ${picked}` : "Pick a block"}</p>
    </div>
  );
}

Props

PropTypeDefault
activeIndexnumber0
classNamestring—
idstring—
itemsDocSlashItem[]DOC_SLASH_ITEMS
onClose() => void—
onPickrequired(id: string) => void—
querystring""

Takes these and every prop of DocSlashMenuProps.

Dependencies

npm
none besides React
Installs with it
18 icons

Source

components/ui/doc-slash-menu.tsx
/* eslint-disable react-refresh/only-export-components */
import * as React from "react";

import { AlertTriangle } from "@/components/icons/alert-triangle";
import { Attatchment01 } from "@/components/icons/attatchment-01";
import { Calculator01 } from "@/components/icons/calculator-01";
import { CheckContained } from "@/components/icons/check-contained";
import { CheckSquareContained } from "@/components/icons/check-square-contained";
import { Code02 } from "@/components/icons/code-02";
import { CloseCircleContained } from "@/components/icons/close-circle-contained";
import { Divider } from "@/components/icons/divider";
import { Image } from "@/components/icons/image";
import { InformationCircleContained } from "@/components/icons/information-circle-contained";
import { Link } from "@/components/icons/link";
import { List } from "@/components/icons/list";
import { ListNumbers } from "@/components/icons/list-numbers";
import { MessageSquare } from "@/components/icons/message-square";
import { Quote } from "@/components/icons/quote";
import { Table } from "@/components/icons/table";
import { Type01 } from "@/components/icons/type-01";
import { Type02 } from "@/components/icons/type-02";
import { cn } from "@/lib/utils";

export interface DocSlashItem {
  /** What the host receives in `onPick`. */
  id: string;
  group: string;
  label: string;
  description: string;
  icon: React.ComponentType<{ size?: number }>;
  /** Other words that find the item, besides its label: `/notice` finds Callout. */
  keywords?: string[];
}

export const DOC_SLASH_ITEMS: DocSlashItem[] = [
  { id: "text", group: "Blocks", label: "Text", description: "Plain paragraph", icon: Type01 },
  { id: "h1", group: "Blocks", label: "Heading 1", description: "Large heading", icon: Type02 },
  { id: "h2", group: "Blocks", label: "Heading 2", description: "Medium heading", icon: Type02 },
  { id: "h3", group: "Blocks", label: "Heading 3", description: "Small heading", icon: Type02 },
  { id: "quote", group: "Blocks", label: "Quote", description: "Block quote", icon: Quote },
  { id: "bulleted", group: "Blocks", label: "Bulleted List", description: "Unordered list", icon: List },
  { id: "numbered", group: "Blocks", label: "Numbered List", description: "Ordered list", icon: ListNumbers },
  { id: "todo", group: "Blocks", label: "To-do", description: "Checkbox list", icon: CheckSquareContained },
  { id: "code", group: "Media", label: "Code Block", description: "Code with a language", icon: Code02 },
  { id: "diagram", group: "Media", label: "Diagram", description: "Flowchart, sequence, Gantt and more", icon: Code02, keywords: ["mermaid", "flowchart", "chart", "graph", "uml"] },
  { id: "image", group: "Media", label: "Image", description: "An image with a caption", icon: Image },
  { id: "file", group: "Media", label: "File", description: "Attach a PDF, zip or any file", icon: Attatchment01, keywords: ["attachment", "upload", "pdf", "zip"] },
  { id: "callout", group: "Media", label: "Callout", description: "Info, warning, danger or success", icon: MessageSquare, keywords: ["notice", "note", "alert"] },
  { id: "info", group: "Media", label: "Info", description: "Info callout", icon: InformationCircleContained },
  { id: "warning", group: "Media", label: "Warning", description: "Warning callout", icon: AlertTriangle },
  { id: "danger", group: "Media", label: "Danger", description: "Danger callout", icon: CloseCircleContained },
  { id: "success", group: "Media", label: "Success", description: "Success callout", icon: CheckContained },
  { id: "table", group: "Media", label: "Table", description: "Rows and columns", icon: Table },
  { id: "latex", group: "Media", label: "LaTeX", description: "Display math ($$)", icon: Calculator01 },
  { id: "bookmark", group: "Media", label: "Bookmark", description: "A link as a card", icon: Link },
  { id: "divider", group: "Media", label: "Divider", description: "Horizontal rule", icon: Divider },
];

export interface DocSlashMenuProps {
  /** What was typed after the slash; filters the labels and keywords. */
  query?: string;
  items?: DocSlashItem[];
  /** Index into `slashMatches(items, query)` of the highlighted item. */
  activeIndex?: number;
  /** Prefix for the option ids; the textarea points `aria-activedescendant` at `${id}-${item.id}`. */
  id?: string;
  onPick: (id: string) => void;
  onClose?: () => void;
  className?: string;
}

/** The items `query` matches, in the order the menu shows them (grouped). */
export function slashMatches(items: DocSlashItem[], query: string) {
  // Every word typed must hit the label or a keyword, so `/priority high` finds "Priority: High".
  const words = query.toLowerCase().split(/\s+/).filter(Boolean);
  const hit = items.filter((i) => {
    const hay = [i.label, ...(i.keywords ?? [])].map((w) => w.toLowerCase());
    return words.every((q) => hay.some((w) => w.includes(q)));
  });
  return [...new Set(hit.map((i) => i.group))].flatMap((g) => hit.filter((i) => i.group === g));
}

/** The "/" block picker: grouped, filtered by `query`. The host drives the highlight with `activeIndex` (focus stays in its text field). */
function DocSlashMenu({ query = "", items = DOC_SLASH_ITEMS, activeIndex = 0, id, onPick, onClose, className }: DocSlashMenuProps) {
  const visible = slashMatches(items, query);
  const active = visible[activeIndex];
  const groups = [...new Set(visible.map((i) => i.group))];
  return (
    <div
      id={id}
      role="listbox"
      aria-label="Blocks"
      className={cn("max-h-64 w-72 overflow-y-auto rounded-lg border border-border-default bg-bg-main shadow-lg", className)}
      onKeyDown={(e) => e.key === "Escape" && onClose?.()}
    >
      {groups.map((group) => (
        <React.Fragment key={group}>
          <div className="px-3 py-1 text-xs font-semibold uppercase text-text-muted">{group}</div>
          {visible
            .filter((i) => i.group === group)
            .map((item) => (
              <button
                key={item.id}
                id={id && `${id}-${item.id}`}
                ref={item === active ? (el) => el?.scrollIntoView({ block: "nearest" }) : undefined}
                type="button"
                role="option"
                tabIndex={-1}
                aria-selected={item === active}
                className={cn("flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-bg-surface-2", item === active && "bg-bg-surface-2")}
                onMouseDown={(e) => e.preventDefault()}
                onClick={() => onPick(item.id)}
              >
                <span className="w-6 shrink-0 text-center text-text-muted">
                  <item.icon size={16} />
                </span>
                <span className="font-medium">{item.label}</span>
                <span className="ml-auto text-xs text-text-muted">{item.description}</span>
              </button>
            ))}
        </React.Fragment>
      ))}
      {visible.length === 0 && <div className="px-3 py-4 text-sm text-text-muted">No matching block</div>}
    </div>
  );
}

export { DocSlashMenu };