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.
Examples
Usage
Props
| Prop | Type | Default |
|---|---|---|
activeIndex | number | 0 |
className | string | — |
create | string | — |
id | string | — |
itemsrequired | DocMentionItem[] | — |
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 };