Vela UI Docsvenizia.ai

Doc Mention Menu

The picker that @ and [[ open in the document editor: people, groups, documents and collections as you type, then Create a new doc.

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

Examples

  • Default
  • Documents

View code

Doc Mention Menu · Default

Installation

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

Code

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

import { DocMentionMenu, mentionMatches, type DocMentionItem } from "@/components/ui/doc-mention-menu";

const ITEMS: DocMentionItem[] = [
  { id: "u1", kind: "user", label: "Linh Tran", description: "Platform" },
  { id: "u2", kind: "user", label: "Minh Pham", description: "Billing" },
  { id: "g1", kind: "group", label: "Platform team", description: "6 people" },
  { id: "g2", kind: "group", label: "Finance", description: "4 people" },
  { id: "d1", kind: "doc", label: "Q4 launch plan" },
  { id: "d2", kind: "doc", label: "Migration runbook" },
  { id: "c1", kind: "collection", label: "Engineering" },
];

export default function Example() {
  const [query, setQuery] = React.useState("");
  const [picked, setPicked] = React.useState("");
  const items = mentionMatches({ items: ITEMS, query });
  return (
    <div className="w-full max-w-xl space-y-2 p-6">
      <label className="flex items-center gap-2 text-sm">
        <span className="font-mono text-text-muted">@</span>
        <input
          aria-label="Mention filter"
          value={query}
          onChange={e => setQuery(e.target.value)}
          placeholder="Keep typing to filter"
          className="min-w-0 flex-1 rounded-md border border-border-default bg-transparent px-3 py-2 outline-none"
        />
      </label>
      <DocMentionMenu items={items} onPick={id => setPicked(ITEMS.find(i => i.id === id)?.label ?? "")} />
      <p className="text-sm text-text-muted">{picked ? `Mentioned: ${picked}` : "Pick someone or something"}</p>
    </div>
  );
}

View code

Doc Mention Menu · Documents

Installation

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

Code

doc-mention-menu-documents.tsx
import * as React from "react";

import { DocMentionMenu, DOC_MENTION_CREATE, mentionMatches, type DocMentionItem } from "@/components/ui/doc-mention-menu";

const ITEMS: DocMentionItem[] = [
  { id: "d1", kind: "doc", label: "Q4 launch plan" },
  { id: "d2", kind: "doc", label: "Migration runbook" },
  { id: "c1", kind: "collection", label: "Engineering" },
  { id: "c2", kind: "collection", label: "Finance" },
];

export default function Example() {
  const [query, setQuery] = React.useState("Run");
  const [made, setMade] = React.useState("");
  const items = mentionMatches({ items: ITEMS, query, kinds: ["doc", "collection"] });
  return (
    <div className="w-full max-w-xl space-y-2 p-6">
      <label className="flex items-center gap-2 text-sm">
        <span className="font-mono text-text-muted">[[</span>
        <input
          aria-label="Link filter"
          value={query}
          onChange={e => setQuery(e.target.value)}
          className="min-w-0 flex-1 rounded-md border border-border-default bg-transparent px-3 py-2 outline-none"
        />
      </label>
      <DocMentionMenu
        items={items}
        create={query.trim() || undefined}
        onPick={id => setMade(id === DOC_MENTION_CREATE ? query.trim() : "")}
      />
      <p className="text-sm text-text-muted">{made ? `Would create a new doc: ${made}` : "Type a new title to see Create"}</p>
    </div>
  );
}

Usage

import * as React from "react";

import { DocMentionMenu, mentionMatches, type DocMentionItem } from "@/components/ui/doc-mention-menu";

const ITEMS: DocMentionItem[] = [
  { id: "u1", kind: "user", label: "Linh Tran", description: "Platform" },
  { id: "u2", kind: "user", label: "Minh Pham", description: "Billing" },
  { id: "g1", kind: "group", label: "Platform team", description: "6 people" },
  { id: "g2", kind: "group", label: "Finance", description: "4 people" },
  { id: "d1", kind: "doc", label: "Q4 launch plan" },
  { id: "d2", kind: "doc", label: "Migration runbook" },
  { id: "c1", kind: "collection", label: "Engineering" },
];

export default function Example() {
  const [query, setQuery] = React.useState("");
  const [picked, setPicked] = React.useState("");
  const items = mentionMatches({ items: ITEMS, query });
  return (
    <div className="w-full max-w-xl space-y-2 p-6">
      <label className="flex items-center gap-2 text-sm">
        <span className="font-mono text-text-muted">@</span>
        <input
          aria-label="Mention filter"
          value={query}
          onChange={e => setQuery(e.target.value)}
          placeholder="Keep typing to filter"
          className="min-w-0 flex-1 rounded-md border border-border-default bg-transparent px-3 py-2 outline-none"
        />
      </label>
      <DocMentionMenu items={items} onPick={id => setPicked(ITEMS.find(i => i.id === id)?.label ?? "")} />
      <p className="text-sm text-text-muted">{picked ? `Mentioned: ${picked}` : "Pick someone or something"}</p>
    </div>
  );
}

Props

PropTypeDefault
activeIndexnumber0
classNamestring—
createstring—
idstring—
itemsrequiredDocMentionItem[]—
onPickrequired(id: string) => void—

Takes these and every prop of DocMentionMenuProps.

Dependencies

npm
none besides React
Installs with it
5 icons

Source

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

import { File } from "@/components/icons/file";
import { FilePlus01 } from "@/components/icons/file-plus-01";
import { Folder } from "@/components/icons/folder";
import { UserProfile01 } from "@/components/icons/user-profile-01";
import { UsersProfiles01 } from "@/components/icons/users-profiles-01";
import { cn } from "@/lib/utils";

export type DocMentionKind = "user" | "group" | "doc" | "collection";

/** Something the `@` menu can mention. `id` is the host's: it is what a notification or a link is made from. */
export interface DocMentionItem {
  id: string;
  kind: DocMentionKind;
  label: string;
  description?: string;
}

/** The menu's own entry for making a document from what was typed; picking it is the host's to answer. */
export const DOC_MENTION_CREATE = "__create__";

const GROUP: Record<DocMentionKind, string> = {
  user: "People",
  group: "Groups",
  doc: "Documents",
  collection: "Collections",
};
const ICON: Record<DocMentionKind, React.ComponentType<{ size?: number }>> = {
  user: UserProfile01,
  group: UsersProfiles01,
  doc: File,
  collection: Folder,
};

/**
 * The items `query` matches, in the order the menu shows them: people, groups, documents, collections. `kinds` limits
 * what is offered (`[[` is for documents and collections), and `limit` caps each kind so a long list stays short.
 */
export function mentionMatches({
  items,
  query,
  kinds = ["user", "group", "doc", "collection"],
  limit = 5,
}: {
  items: DocMentionItem[];
  query: string;
  kinds?: DocMentionKind[];
  limit?: number;
}) {
  const q = query.toLowerCase();
  return (["user", "group", "doc", "collection"] as const).flatMap(kind =>
    kinds.includes(kind) ? items.filter(i => i.kind === kind && i.label.toLowerCase().includes(q)).slice(0, limit) : [],
  );
}

export interface DocMentionMenuProps {
  /** Matches from `mentionMatches`; the menu shows them as given. */
  items: DocMentionItem[];
  /** Offer "Create a new doc" for what was typed; picking it calls `onPick` with `DOC_MENTION_CREATE`. */
  create?: string;
  activeIndex?: number;
  /** Prefix for the option ids; the text field points `aria-activedescendant` at `${id}-${index}`. */
  id?: string;
  onPick: (id: string) => void;
  className?: string;
}

/** The `@` and `[[` picker: people, groups, documents and collections as you type, then "Create a new doc". Focus stays in the text field. */
function DocMentionMenu({ items, create, activeIndex = 0, id, onPick, className }: DocMentionMenuProps) {
  const rows = [...items, ...(create ? [{ id: DOC_MENTION_CREATE, kind: "doc" as const, label: `Create "${create}"` }] : [])];
  return (
    <div
      id={id}
      role="listbox"
      aria-label="Mention"
      className={cn("max-h-64 w-72 overflow-y-auto rounded-lg border border-border-default bg-bg-main shadow-lg", className)}
    >
      {rows.map((item, i) => {
        const made = item.id === DOC_MENTION_CREATE;
        const Icon = made ? FilePlus01 : ICON[item.kind];
        return (
          <React.Fragment key={item.id}>
            {!made && item.kind !== rows[i - 1]?.kind && (
              <div className="px-3 py-1 text-xs font-semibold text-text-muted uppercase">{GROUP[item.kind]}</div>
            )}
            <button
              id={id && `${id}-${i}`}
              ref={i === activeIndex ? el => el?.scrollIntoView({ block: "nearest" }) : undefined}
              type="button"
              role="option"
              tabIndex={-1}
              aria-selected={i === activeIndex}
              className={cn(
                "flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-bg-surface-2",
                i === activeIndex && "bg-bg-surface-2",
                made && "border-t border-border-default",
              )}
              onMouseDown={e => e.preventDefault()}
              onClick={() => onPick(item.id)}
            >
              <span className="w-6 shrink-0 text-center text-text-muted">
                <Icon size={16} />
              </span>
              <span className="truncate font-medium">{item.label}</span>
              {"description" in item && item.description && (
                <span className="ml-auto truncate text-xs text-text-muted">{item.description}</span>
              )}
            </button>
          </React.Fragment>
        );
      })}
      {rows.length === 0 && <div className="px-3 py-4 text-sm text-text-muted">No one and nothing matches</div>}
    </div>
  );
}

export { DocMentionMenu };