Doc Slash Menu
The grouped, filterable block picker that a slash opens in the document editor.
Examples
Usage
Props
| Prop | Type | Default |
|---|---|---|
activeIndex | number | 0 |
className | string | — |
id | string | — |
items | DocSlashItem[] | DOC_SLASH_ITEMS |
onClose | () => void | — |
onPickrequired | (id: string) => void | — |
query | string | "" |
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 };