Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/__registry__/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2239,6 +2239,20 @@ export const Index: Record<string, any> = {
source: "",
chunks: []
},
"mention-controlled-demo": {
name: "mention-controlled-demo",
description: "",
type: "registry:example",
registryDependencies: undefined,
files: [{
path: "registry/default/examples/mention-controlled-demo.tsx",
type: "registry:example",
target: ""
}],
component: React.lazy(() => import("@/registry/default/examples/mention-controlled-demo.tsx")),
source: "",
chunks: []
},
"mention-custom-filter-demo": {
name: "mention-custom-filter-demo",
description: "",
Expand Down
4 changes: 4 additions & 0 deletions docs/content/docs/components/mention.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ return (

<ComponentTabs name="mention-custom-filter-demo" />

### Controlled

<ComponentTabs name="mention-controlled-demo" />

## API Reference

### Mention
Expand Down
15 changes: 15 additions & 0 deletions docs/public/r/mention-controlled-demo.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "mention-controlled-demo",
"dependencies": [
"@diceui/mention"
],
"files": [
{
"path": "examples/mention-controlled-demo.tsx",
"content": "\"use client\";\n\nimport * as React from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport {\n Mention,\n MentionContent,\n MentionInput,\n MentionItem,\n} from \"@/registry/default/ui/mention\";\n\nconst users = [\n {\n id: \"1\",\n name: \"Olivia Martin\",\n email: \"olivia@email.com\",\n },\n {\n id: \"2\",\n name: \"Isabella Nguyen\",\n email: \"isabella@email.com\",\n },\n {\n id: \"3\",\n name: \"Emma Wilson\",\n email: \"emma@email.com\",\n },\n {\n id: \"4\",\n name: \"Jackson Lee\",\n email: \"jackson@email.com\",\n },\n {\n id: \"5\",\n name: \"William Kim\",\n email: \"will@email.com\",\n },\n];\n\ninterface Message {\n id: string;\n content: string;\n mentions: string[];\n}\n\nfunction renderMessageContent(content: string, mentions: string[]) {\n if (mentions.length === 0) return content;\n\n const escaped = mentions.map((n) => n.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\"));\n const pattern = new RegExp(`(@(?:${escaped.join(\"|\")}))`, \"g\");\n\n return content.split(pattern).map((part, i) =>\n part.startsWith(\"@\") && mentions.includes(part.slice(1)) ? (\n <span\n key={i}\n className=\"rounded bg-blue-200 py-px text-blue-950 dark:bg-blue-800 dark:text-blue-50\"\n >\n {part}\n </span>\n ) : (\n part\n )\n );\n}\n\nexport default function MentionControlledDemo() {\n const [open, setOpen] = React.useState(false);\n const [value, setValue] = React.useState<string[]>([]);\n const [inputValue, setInputValue] = React.useState(\"\");\n const [messages, setMessages] = React.useState<Message[]>([]);\n\n function onSend() {\n if (!inputValue.trim()) return;\n\n setMessages((prev) => [\n ...prev,\n {\n id: crypto.randomUUID(),\n content: inputValue,\n mentions: value,\n },\n ]);\n setInputValue(\"\");\n setValue([]);\n }\n\n return (\n <div className=\"grid h-full w-full max-w-[400px] grid-rows-[1fr_auto_1fr] gap-3\">\n <div className=\"relative flex flex-col justify-end overflow-y-hidden\">\n <div className=\"pointer-events-none absolute inset-x-0 top-0 z-10 h-12 bg-linear-to-b from-white to-transparent dark:from-fd-background\" />\n {messages.length > 0 && (\n <div className=\"flex flex-col gap-2\">\n {messages.map((message) => (\n <div\n key={message.id}\n className=\"rounded-md border border-zinc-200 bg-zinc-50 p-3 text-sm dark:border-zinc-800 dark:bg-zinc-900\"\n >\n <p className=\"whitespace-pre-wrap text-zinc-900 dark:text-zinc-100\">\n {renderMessageContent(message.content, message.mentions)}\n </p>\n </div>\n ))}\n </div>\n )}\n </div>\n <div className=\"flex flex-col gap-3\">\n <Mention\n open={open}\n onOpenChange={setOpen}\n value={value}\n onValueChange={setValue}\n inputValue={inputValue}\n onInputValueChange={setInputValue}\n >\n <MentionInput placeholder=\"Type @ to mention someone...\" asChild>\n <Textarea />\n </MentionInput>\n <MentionContent>\n {users.map((user) => (\n <MentionItem\n key={user.id}\n value={user.name}\n className=\"flex-col items-start gap-0.5\"\n >\n <span className=\"text-sm\">{user.name}</span>\n <span className=\"text-muted-foreground text-xs\">\n {user.email}\n </span>\n </MentionItem>\n ))}\n </MentionContent>\n </Mention>\n <Button onClick={onSend} disabled={!inputValue.trim()}>\n Send\n </Button>\n </div>\n <div />\n </div>\n );\n}\n",
"type": "registry:example",
"target": ""
}
],
"type": "registry:example"
}
13 changes: 13 additions & 0 deletions docs/public/r/registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -2583,6 +2583,19 @@
],
"type": "registry:example"
},
{
"name": "mention-controlled-demo",
"dependencies": [
"@diceui/mention"
],
"files": [
{
"path": "examples/mention-controlled-demo.tsx",
"type": "registry:example"
}
],
"type": "registry:example"
},
{
"name": "mention-custom-filter-demo",
"dependencies": [
Expand Down
141 changes: 141 additions & 0 deletions docs/registry/default/examples/mention-controlled-demo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
"use client";

import * as React from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
Mention,
MentionContent,
MentionInput,
MentionItem,
} from "@/registry/default/ui/mention";

const users = [
{
id: "1",
name: "Olivia Martin",
email: "olivia@email.com",
},
{
id: "2",
name: "Isabella Nguyen",
email: "isabella@email.com",
},
{
id: "3",
name: "Emma Wilson",
email: "emma@email.com",
},
{
id: "4",
name: "Jackson Lee",
email: "jackson@email.com",
},
{
id: "5",
name: "William Kim",
email: "will@email.com",
},
];

interface Message {
id: string;
content: string;
mentions: string[];
}

function renderMessageContent(content: string, mentions: string[]) {
if (mentions.length === 0) return content;

const escaped = mentions.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const pattern = new RegExp(`(@(?:${escaped.join("|")}))`, "g");

return content.split(pattern).map((part, i) =>
part.startsWith("@") && mentions.includes(part.slice(1)) ? (
<span
key={i}
className="rounded bg-blue-200 py-px text-blue-950 dark:bg-blue-800 dark:text-blue-50"
>
{part}
</span>
) : (
part
)
);
}

export default function MentionControlledDemo() {
const [open, setOpen] = React.useState(false);
const [value, setValue] = React.useState<string[]>([]);
const [inputValue, setInputValue] = React.useState("");
const [messages, setMessages] = React.useState<Message[]>([]);

function onSend() {
if (!inputValue.trim()) return;

setMessages((prev) => [
...prev,
{
id: crypto.randomUUID(),
content: inputValue,
mentions: value,
},
]);
setInputValue("");
setValue([]);
}

return (
<div className="grid h-full w-full max-w-[400px] grid-rows-[1fr_auto_1fr] gap-3">
<div className="relative flex flex-col justify-end overflow-y-hidden">
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-12 bg-linear-to-b from-white to-transparent dark:from-fd-background" />
{messages.length > 0 && (
<div className="flex flex-col gap-2">
{messages.map((message) => (
<div
key={message.id}
className="rounded-md border border-zinc-200 bg-zinc-50 p-3 text-sm dark:border-zinc-800 dark:bg-zinc-900"
>
<p className="whitespace-pre-wrap text-zinc-900 dark:text-zinc-100">
{renderMessageContent(message.content, message.mentions)}
</p>
</div>
))}
</div>
)}
</div>
<div className="flex flex-col gap-3">
<Mention
open={open}
onOpenChange={setOpen}
value={value}
onValueChange={setValue}
inputValue={inputValue}
onInputValueChange={setInputValue}
>
<MentionInput placeholder="Type @ to mention someone..." asChild>
<Textarea />
</MentionInput>
<MentionContent>
{users.map((user) => (
<MentionItem
key={user.id}
value={user.name}
className="flex-col items-start gap-0.5"
>
<span className="text-sm">{user.name}</span>
<span className="text-muted-foreground text-xs">
{user.email}
</span>
</MentionItem>
))}
</MentionContent>
</Mention>
<Button onClick={onSend} disabled={!inputValue.trim()}>
Send
</Button>
</div>
<div />
</div>
);
}
11 changes: 11 additions & 0 deletions docs/registry/registry-examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1251,6 +1251,17 @@ export const examples: Registry["items"] = [
},
],
},
{
name: "mention-controlled-demo",
type: "registry:example",
dependencies: ["@diceui/mention"],
files: [
{
path: "examples/mention-controlled-demo.tsx",
type: "registry:example",
},
],
},
{
name: "mention-custom-filter-demo",
type: "registry:example",
Expand Down
16 changes: 11 additions & 5 deletions packages/mention/src/mention-highlighter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,14 +130,20 @@ const MentionHighlighter = React.memo(
}, [inputStyle, style, context.dir]);

const onSegmentsRender = React.useCallback(() => {
const inputElement = context.inputRef.current;
if (!inputElement) return null;
const value = context.inputValue;

const { value } = inputElement;
const segments: React.ReactNode[] = [];
let lastIndex = 0;

for (const { start, end } of context.mentions) {
// Filter out mentions with invalid positions
const validMentions = context.mentions.filter(
(mention) =>
mention.start >= 0 &&
mention.end <= value.length &&
mention.start < mention.end
);

for (const { start, end } of validMentions) {
// Add text before mention
if (start > lastIndex) {
segments.push(
Expand Down Expand Up @@ -170,7 +176,7 @@ const MentionHighlighter = React.memo(
segments.push(<span key="space">&nbsp;</span>);

return segments;
}, [context.inputRef, context.mentions]);
}, [context.inputValue, context.mentions]);

if (!inputStyle) return null;

Expand Down
12 changes: 12 additions & 0 deletions packages/mention/src/mention-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ const MentionInput = React.forwardRef<InputElement, MentionInputProps>(
const context = useMentionContext(INPUT_NAME);
const composedRef = useComposedRefs(forwardedRef, context.inputRef);

// Sync controlled inputValue to the actual input element
React.useEffect(() => {
const inputElement = context.inputRef.current;
if (!inputElement) return;

// Only sync if the values differ (avoid unnecessary DOM updates)
if (inputElement.value !== context.inputValue) {
inputElement.value = context.inputValue;
}
}, [context.inputValue, context.inputRef]);

const getTextWidth = React.useCallback(
(text: string, input: InputElement) => {
const style = window.getComputedStyle(input);
Expand Down Expand Up @@ -299,6 +310,7 @@ const MentionInput = React.forwardRef<InputElement, MentionInputProps>(
context.onInputValueChange,
context.inputValue,
context.onMentionsChange,
context.onValueChange,
onMentionUpdate,
context.disabled,
context.readonly,
Expand Down