Mentions for everyone! This plugin allows the user to choose an entry from a list. After selection an entry the search text will be replace with the selected entity. The list of suggestions mentions needs to contain at least a name to display. If desired a link and/or an avatar image can be provided.
While the suggestion popover is open, the user can close it by pressing ESC. This will be stored for as long as the the selection stays inside the word that triggered the search. After the selection left this word once the escape behaviour will be reset. The suggestions will appear again once the user selects the word that that triggered the selection.
npm install @draft-js-plugins/editornpm install @draft-js-plugins/mentionPlease checkout the 'Simple Example' further down the page.The plugin ships with a default styling available at this location in the installed package: node_modules/@draft-js-plugins/mention/lib/plugin.css
npm i style-loader css-loader --save-devmodule.exports = { module: { loaders: [ { test: /plugin\.css$/, loaders: ['style-loader', 'css'], }, ], }, }; import '@draft-js-plugins/mention/lib/plugin.css';MentionSuggestions. Defaults to MentionSuggestions.popoverComponentdefaultSuggestionsFilter. As first argument it takes the search term as a String. The second argument is an array of mentions. The third argument is a trigger that is used to filter multi mentions. The function returns the filter list based on substring matches.import { defaultSuggestionsFilter } from '@draft-js-plugins/mention';import React, { ReactElement, useCallback, useMemo, useRef, useState, } from 'react'; import { EditorState } from 'draft-js'; import Editor from '@draft-js-plugins/editor'; import createMentionPlugin, { defaultSuggestionsFilter, } from '@draft-js-plugins/mention'; import editorStyles from './SimpleMentionEditor.module.css'; import mentions from './Mentions'; export default function SimpleMentionEditor(): ReactElement { const ref = useRef<Editor>(null); const [editorState, setEditorState] = useState(() => EditorState.createEmpty() ); const [open, setOpen] = useState(false); const [suggestions, setSuggestions] = useState(mentions); const { MentionSuggestions, plugins } = useMemo(() => { const mentionPlugin = createMentionPlugin(); // eslint-disable-next-line no-shadow const { MentionSuggestions } = mentionPlugin; // eslint-disable-next-line no-shadow const plugins = [mentionPlugin]; return { plugins, MentionSuggestions }; }, []); const onOpenChange = useCallback((_open: boolean) => { setOpen(_open); }, []); const onSearchChange = useCallback(({ value }: { value: string }) => { setSuggestions(defaultSuggestionsFilter(value, mentions)); }, []); return ( <div className={editorStyles.editor} onClick={() => { ref.current!.focus(); }} > <Editor editorKey={'editor'} editorState={editorState} onChange={setEditorState} plugins={plugins} ref={ref} /> <MentionSuggestions open={open} onOpenChange={onOpenChange} suggestions={suggestions} onSearchChange={onSearchChange} onAddMention={() => { // get the mention object selected }} /> </div> ); } import { MentionData } from '@draft-js-plugins/mention'; const mentions: MentionData[] = [ { name: 'Matthew Russell', link: 'https://twitter.com/mrussell247', avatar: 'https://pbs.twimg.com/profile_images/517863945/mattsailing_400x400.jpg', }, { name: 'Julian Krispel-Samsel', link: 'https://twitter.com/juliandoesstuff', avatar: 'https://avatars2.githubusercontent.com/u/1188186?v=3&s=400', }, { name: 'Jyoti Puri', link: 'https://twitter.com/jyopur', avatar: 'https://avatars0.githubusercontent.com/u/2182307?v=3&s=400', }, { name: 'Max Stoiber', link: 'https://twitter.com/mxstbr', avatar: 'https://avatars0.githubusercontent.com/u/7525670?s=200&v=4', }, { name: 'Nik Graf', link: 'https://twitter.com/nikgraf', avatar: 'https://avatars0.githubusercontent.com/u/223045?v=3&s=400', }, { name: 'Pascal Brandt', link: 'https://twitter.com/psbrandt', avatar: 'https://pbs.twimg.com/profile_images/688487813025640448/E6O6I011_400x400.png', }, ]; export default mentions; .editor { box-sizing: border-box; border: 1px solid #ddd; cursor: text; padding: 16px; border-radius: 2px; margin-bottom: 2em; box-shadow: inset 0px 1px 8px -3px #ABABAB; background: #fefefe; } .editor :global(.public-DraftEditor-content) { min-height: 140px; } import React, { MouseEvent, ReactElement, useCallback, useMemo, useRef, useState, } from 'react'; import { EditorState } from 'draft-js'; import Editor from '@draft-js-plugins/editor'; import createMentionPlugin, { defaultSuggestionsFilter, MentionData, MentionPluginTheme, } from '@draft-js-plugins/mention'; import editorStyles from './CustomMentionEditor.module.css'; import mentionsStyles from './MentionsStyles.module.css'; import mentions from './Mentions'; export interface EntryComponentProps { className?: string; onMouseDown(event: MouseEvent): void; onMouseUp(event: MouseEvent): void; onMouseEnter(event: MouseEvent): void; role: string; id: string; 'aria-selected'?: boolean | 'false' | 'true'; theme?: MentionPluginTheme; mention: MentionData; isFocused: boolean; searchValue?: string; } function Entry(props: EntryComponentProps): ReactElement { const { mention, theme, searchValue, // eslint-disable-line @typescript-eslint/no-unused-vars isFocused, // eslint-disable-line @typescript-eslint/no-unused-vars ...parentProps } = props; return ( <div {...parentProps}> <div className={theme?.mentionSuggestionsEntryContainer}> <div className={theme?.mentionSuggestionsEntryContainerLeft}> <img src={mention.avatar} className={theme?.mentionSuggestionsEntryAvatar} role="presentation" /> </div> <div className={theme?.mentionSuggestionsEntryContainerRight}> <div className={theme?.mentionSuggestionsEntryText}> {mention.name} </div> <div className={theme?.mentionSuggestionsEntryTitle}> {mention.title} </div> </div> </div> </div> ); } export default function CustomMentionEditor(): ReactElement { const ref = useRef<Editor>(null); const [editorState, setEditorState] = useState(() => EditorState.createEmpty() ); const [open, setOpen] = useState(false); const [suggestions, setSuggestions] = useState(mentions); const { MentionSuggestions, plugins } = useMemo(() => { const mentionPlugin = createMentionPlugin({ entityMutability: 'IMMUTABLE', theme: mentionsStyles, mentionPrefix: '@', supportWhitespace: true, }); // eslint-disable-next-line no-shadow const { MentionSuggestions } = mentionPlugin; // eslint-disable-next-line no-shadow const plugins = [mentionPlugin]; return { plugins, MentionSuggestions }; }, []); const onChange = useCallback((_editorState: EditorState) => { setEditorState(_editorState); }, []); const onOpenChange = useCallback((_open: boolean) => { setOpen(_open); }, []); const onSearchChange = useCallback(({ value }: { value: string }) => { setSuggestions(defaultSuggestionsFilter(value, mentions)); }, []); return ( <div className={editorStyles.editor} onClick={() => { ref.current!.focus(); }} > <Editor editorKey={'editor'} editorState={editorState} onChange={onChange} plugins={plugins} ref={ref} /> <MentionSuggestions open={open} onOpenChange={onOpenChange} suggestions={suggestions} onSearchChange={onSearchChange} onAddMention={() => { // get the mention object selected }} entryComponent={Entry} popoverContainer={({ children }) => <div>{children}</div>} /> </div> ); } .mention { color: #4a85bb; text-decoration: none; } .mentionSuggestions { border-top: 1px solid #eee; background: #fff; border-radius: 2px; cursor: pointer; padding-top: 8px; padding-bottom: 8px; display: flex; flex-direction: column; box-sizing: border-box; transform-origin: 50% 0%; transform: scaleY(0); margin: -16px; } .mentionSuggestionsEntryContainer { display: table; width: 100%; } .mentionSuggestionsEntryContainerLeft, .mentionSuggestionsEntryContainerRight { display: table-cell; vertical-align: middle; } .mentionSuggestionsEntryContainerRight { width: 100%; padding-left: 8px; } .mentionSuggestionsEntry { padding: 7px 10px 3px 10px; transition: background-color 0.4s cubic-bezier(.27,1.27,.48,.56); } .mentionSuggestionsEntry:active { background-color: #cce7ff; } .mentionSuggestionsEntryFocused { composes: mentionSuggestionsEntry; background-color: #e6f3ff; } .mentionSuggestionsEntryText, .mentionSuggestionsEntryTitle { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .mentionSuggestionsEntryText { } .mentionSuggestionsEntryTitle { font-size: 80%; color: #a7a7a7; } .mentionSuggestionsEntryAvatar { display: block; width: 30px; height: 30px; border-radius: 50%; } import { MentionData } from '@draft-js-plugins/mention'; const mentions: MentionData[] = [ { name: 'Matthew Russell', title: 'Senior Software Engineer', avatar: 'https://pbs.twimg.com/profile_images/517863945/mattsailing_400x400.jpg', }, { name: 'Julian Krispel-Samsel', title: 'United Kingdom', avatar: 'https://avatars2.githubusercontent.com/u/1188186?v=3&s=400', }, { name: 'Jyoti Puri', title: 'New Delhi, India', avatar: 'https://avatars0.githubusercontent.com/u/2182307?v=3&s=400', }, { name: 'Max Stoiber', title: 'Travels around the world, brews coffee, skis mountains and makes stuff on the web.', avatar: 'https://avatars0.githubusercontent.com/u/7525670?s=200&v=4', }, { name: 'Nik Graf', title: 'Passionate about Software Architecture, UX, Skiing & Triathlons', avatar: 'https://avatars0.githubusercontent.com/u/223045?v=3&s=400', }, { name: 'Pascal Brandt', title: 'HeathIT hacker and researcher', avatar: 'https://pbs.twimg.com/profile_images/688487813025640448/E6O6I011_400x400.png', }, { name: 'Łukasz Bąk', title: 'Randomly Generated User', avatar: 'https://randomuser.me/api/portraits/men/36.jpg', }, { name: '佐々木 小次郎', title: 'Famous Japanese swordsman (SAMURAI)', avatar: 'https://upload.wikimedia.org/wikipedia/commons/0/08/Sasaki-Ganryu-%28Kojiro%29-by-Utagawa-Kuniyoshi-1845.png', url: 'https://en.wikipedia.org/wiki/Sasaki_Kojir%C5%8D', }, ]; export default mentions; .editor { box-sizing: border-box; border: 1px solid #ddd; cursor: text; padding: 16px; border-radius: 2px; margin-bottom: 2em; box-shadow: inset 0px 1px 8px -3px #ABABAB; background: #fefefe; } .editor :global(.public-DraftEditor-content) { min-height: 140px; } import React, { ReactElement, useCallback, useMemo, useRef, useState, } from 'react'; import { EditorState } from 'draft-js'; import Editor from '@draft-js-plugins/editor'; import createMentionPlugin from '@draft-js-plugins/mention'; import editorStyles from './RemoteMentionEditor.module.css'; export default function RemoteMentionEditor(): ReactElement { const ref = useRef<Editor>(null); const [editorState, setEditorState] = useState(() => EditorState.createEmpty() ); const [open, setOpen] = useState(false); const [suggestions, setSuggestions] = useState([]); const { MentionSuggestions, plugins } = useMemo(() => { const mentionPlugin = createMentionPlugin(); // eslint-disable-next-line no-shadow const { MentionSuggestions } = mentionPlugin; // eslint-disable-next-line no-shadow const plugins = [mentionPlugin]; return { plugins, MentionSuggestions }; }, []); const onOpenChange = useCallback((_open: boolean) => { setOpen(_open); }, []); const onSearchChange = useCallback(({ value }: { value: string }) => { // An import statment would break server-side rendering. require('whatwg-fetch'); // eslint-disable-line global-require // while you normally would have a dynamic server that takes the value as // a workaround we use this workaround to show different results let url = '/data/mentionsA.json'; if (value.length === 1) { url = '/data/mentionsB.json'; } else if (value.length > 1) { url = '/data/mentionsC.json'; } fetch(url) .then((response) => response.json()) .then((data) => { setSuggestions(data); }); }, []); return ( <div className={editorStyles.editor} onClick={() => { ref.current!.focus(); }} > <Editor editorKey={'editor'} editorState={editorState} onChange={setEditorState} plugins={plugins} ref={ref} /> <MentionSuggestions open={open} onOpenChange={onOpenChange} suggestions={suggestions} onSearchChange={onSearchChange} onAddMention={() => { // get the mention object selected }} /> </div> ); } .editor { box-sizing: border-box; border: 1px solid #ddd; cursor: text; padding: 16px; border-radius: 2px; margin-bottom: 2em; box-shadow: inset 0px 1px 8px -3px #ABABAB; background: #fefefe; } .editor :global(.public-DraftEditor-content) { min-height: 140px; } import React, { ReactElement, useCallback, useMemo, useRef, useState, } from 'react'; import { EditorState } from 'draft-js'; import Editor from '@draft-js-plugins/editor'; import createMentionPlugin, { defaultSuggestionsFilter, } from '@draft-js-plugins/mention'; import editorStyles from './CustomComponentMentionEditor.module.css'; import mentions from './Mentions'; export default function CustomComponentMentionEditor(): ReactElement { const ref = useRef<Editor>(null); const [editorState, setEditorState] = useState(() => EditorState.createEmpty() ); const [open, setOpen] = useState(false); const [suggestions, setSuggestions] = useState(mentions); const { MentionSuggestions, plugins } = useMemo(() => { const mentionPlugin = createMentionPlugin({ mentionComponent(mentionProps) { return ( <span className={mentionProps.className} // eslint-disable-next-line no-alert onClick={() => alert('Clicked on the Mention!')} > {mentionProps.children} </span> ); }, }); // eslint-disable-next-line no-shadow const { MentionSuggestions } = mentionPlugin; // eslint-disable-next-line no-shadow const plugins = [mentionPlugin]; return { plugins, MentionSuggestions }; }, []); const onOpenChange = useCallback((_open: boolean) => { setOpen(_open); }, []); const onSearchChange = useCallback( ({ trigger, value }: { trigger: string; value: string }) => { setSuggestions(defaultSuggestionsFilter(value, mentions, trigger)); }, [] ); return ( <div className={editorStyles.editor} onClick={() => { ref.current!.focus(); }} > <Editor editorKey={'editor'} editorState={editorState} onChange={setEditorState} plugins={plugins} ref={ref} /> <MentionSuggestions open={open} onOpenChange={onOpenChange} suggestions={suggestions} onSearchChange={onSearchChange} onAddMention={() => { // get the mention object selected }} /> </div> ); } .editor { box-sizing: border-box; border: 1px solid #ddd; cursor: text; padding: 16px; border-radius: 2px; margin-bottom: 2em; box-shadow: inset 0px 1px 8px -3px #ABABAB; background: #fefefe; } .editor :global(.public-DraftEditor-content) { min-height: 140px; } import React, { ReactElement, useRef, useState, useCallback } from 'react'; import { EditorState } from 'draft-js'; import Editor from '@draft-js-plugins/editor'; import createMentionPlugin, { defaultSuggestionsFilter, } from '@draft-js-plugins/mention'; import editorStyles from './MultiMentionTriggers.module.css'; import mentions from './Mentions'; const mentionPlugin = createMentionPlugin({ mentionTrigger: ['@', '#'], mentionPrefix: (trigger) => trigger, }); const { MentionSuggestions } = mentionPlugin; const plugins = [mentionPlugin]; interface MentionData { link?: string; avatar?: string; name: string; id?: null | string | number; // eslint-disable-next-line @typescript-eslint/no-explicit-any [x: string]: any; } const SimpleMentionEditor = (): ReactElement => { const ref = useRef<Editor>(null); const [editorState, setEditorState] = useState(EditorState.createEmpty()); const [open, setOpen] = useState(false); const [suggestions, setSuggestions] = useState<MentionData[]>(mentions['@']); const onChange = useCallback((_editorState: EditorState) => { setEditorState(_editorState); }, []); const onOpenChange = useCallback((_open: boolean) => { setOpen(_open); }, []); const onSearchChange = useCallback( ({ trigger, value }: { trigger: string; value: string }) => { setSuggestions( defaultSuggestionsFilter(value, mentions, trigger) as MentionData[] ); }, [] ); return ( <div className={editorStyles.editor} onClick={() => { ref.current!.focus(); }} > <Editor editorState={editorState} onChange={onChange} plugins={plugins} ref={ref} /> <MentionSuggestions open={open} onOpenChange={onOpenChange} onSearchChange={onSearchChange} suggestions={suggestions} onAddMention={() => { // get the mention object selected }} /> </div> ); }; export default SimpleMentionEditor; .editor { box-sizing: border-box; border: 1px solid #ddd; cursor: text; padding: 16px; border-radius: 2px; margin-bottom: 2em; box-shadow: inset 0px 1px 8px -3px #ABABAB; background: #fefefe; } .editor :global(.public-DraftEditor-content) { min-height: 140px; }