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
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ A React component that enables Facebook/Twitter-style @mentions and tagging in t

- **Flexible Triggers** — any character, string, or custom `RegExp` (`@`, `#`, `:`, or your own)
- **Async Data Loading** — real-time filtering with debouncing, `AbortSignal` cancellation, and cursor pagination
- **Grouped Suggestions** — return page sections for GitHub-style Users/Teams suggestion lists
- **Caret Aware** — detect when the caret overlaps mentions and style them via `data-mention-selection`
- **Inline Autocomplete** — ghost-text hints accepted with Tab, Enter, or arrow keys
- **Tailwind v4 Ready** — first-class support for Tailwind CSS v4 utility styling
Expand Down Expand Up @@ -319,6 +320,31 @@ const fetchUsersPage = async (
;<Mention trigger="@" data={fetchUsersPage} debounceMs={150} />
```

Providers may return grouped sections instead of flat `items`. Section labels render as non-focusable headers, while keyboard navigation and `renderSuggestion` indexes still count only selectable suggestions:

```tsx
import type { MentionDataPage } from 'react-mentions-ts'

type DirectoryEntry = { id: string; display: string }

const fetchDirectory = async (query: string): Promise<MentionDataPage<DirectoryEntry>> => ({
sections: [
{
id: 'users',
label: 'Users',
items: await searchUsers(query),
},
{
id: 'teams',
label: 'Teams',
items: await searchTeams(query),
},
],
})

;<Mention trigger="@" data={fetchDirectory} />
```

Redux-Saga and similar async layers can bridge pagination by returning a promise from `data` and resolving it from the saga:

```tsx
Expand All @@ -345,6 +371,7 @@ The [live demo](https://hbmartin.github.io/react-mentions-ts/) includes many rea
| **Caret Mention States** | Style mentions based on caret overlap via `data-mention-selection` attributes |
| **Inline Autocomplete** | Ghost-text completions accepted with Tab, Enter, or arrow keys |
| **Async GitHub Mentions** | Live GitHub API search with debouncing, cancellation, and stale-result suppression |
| **Grouped Suggestions** | One `@` provider rendering grouped Users and Teams sections |
| **Emoji Support** | Mix people mentions with emoji search powered by a JSON data source |
| **Suggestions Portal** | Render suggestions anywhere in the DOM for modals, drawers, or fixed toolbars |
| **Custom Container** | Wrap suggestions in bespoke UI chrome — badges, headlines, or analytics |
Expand Down Expand Up @@ -715,6 +742,7 @@ Review the diff and run your test suite after applying it.
| ------------------------------------------ | -------------------------------------------------------------------- |
| Async data via Promises with `AbortSignal` | `data` accepts `(query, { signal, cursor, reason }) => Promise<...>` |
| Cursor-paginated async suggestions | Return `{ items, nextCursor, hasMore }` from `data` |
| Grouped async suggestions | Return `{ sections: [{ label, items }] }` from `data` |
| Debounced async queries | `debounceMs` on `Mention` |
| Cap suggestion count | `maxSuggestions` on `Mention` |
| Caret-aware mention styling | `onMentionSelectionChange`, `data-mention-selection` attribute |
Expand Down
2 changes: 2 additions & 0 deletions demo/src/examples/Examples.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import CustomSuggestionsContainer from './CustomSuggestionsContainer'
import CutCopyPaste from './CutCopyPaste'
import Emojis from './Emojis'
import EmptyAndError from './EmptyAndError'
import GroupedSuggestions from './GroupedSuggestions'
import InlineAutocomplete from './InlineAutocomplete'
import LeftAnchored from './LeftAnchored'
import MentionSelection from './MentionSelection'
Expand Down Expand Up @@ -96,6 +97,7 @@ export default function Examples() {
<MentionSelection data={users} />
<InlineAutocomplete data={users} />
<AsyncGithubUserMentions />
<GroupedSuggestions />
<PaginatedUsers />
<EmptyAndError />
<RichSuggestionData />
Expand Down
99 changes: 99 additions & 0 deletions demo/src/examples/GroupedSuggestions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import React, { useState } from 'react'

import { Mention, MentionsInput } from '../../../src'
import type {
MentionDataItem,
MentionDataPage,
MentionSearchContext,
MentionsInputChangeEvent,
} from '../../../src'
import ExampleCard from './ExampleCard'
import {
mentionPillClass,
mergeClassNames,
multilineMentionsClassNames,
} from './mentionsClassNames'
import { waitForAbortableDelay } from './waitForAbortableDelay'

type DirectoryExtra = {
detail: string
}

const USERS: MentionDataItem<DirectoryExtra>[] = [
{ id: 'user:ada', display: 'Ada Lovelace', detail: 'Engineering' },
{ id: 'user:grace', display: 'Grace Hopper', detail: 'Platform' },
{ id: 'user:margaret', display: 'Margaret Hamilton', detail: 'Reliability' },
]

const TEAMS: MentionDataItem<DirectoryExtra>[] = [
{ id: 'team:frontend', display: 'Frontend Team', detail: 'Design systems' },
{ id: 'team:platform', display: 'Platform Team', detail: 'Infrastructure' },
{ id: 'team:support', display: 'Support Team', detail: 'Customer operations' },
]

const groupedClassNames = mergeClassNames(multilineMentionsClassNames, {
suggestionsList: 'divide-y-0',
suggestionSection: 'border-t border-slate-100 bg-slate-50 px-4 py-2 text-xs text-slate-500',
suggestionItem: 'px-4 py-2.5 text-sm text-slate-700',
suggestionItemFocused: 'bg-indigo-50 text-indigo-700',
})

const matchesQuery = (item: MentionDataItem<DirectoryExtra>, query: string): boolean => {
const normalizedQuery = query.toLocaleLowerCase()
return [item.display, item.detail, String(item.id)].some((value) =>
(value ?? '').toLocaleLowerCase().includes(normalizedQuery)
)
}

async function fetchGroupedDirectory(
query: string,
{ signal }: MentionSearchContext
): Promise<MentionDataPage<DirectoryExtra>> {
await waitForAbortableDelay(180, signal)

return {
sections: [
{
id: 'users',
label: 'Users',
items: USERS.filter((item) => matchesQuery(item, query)),
},
{
id: 'teams',
label: 'Teams',
items: TEAMS.filter((item) => matchesQuery(item, query)),
},
],
}
}

export default function GroupedSuggestions() {
const [value, setValue] = useState('')
const onMentionsChange = (change: MentionsInputChangeEvent<DirectoryExtra>) => {
setValue(change.value)
}

return (
<ExampleCard
title="Grouped suggestions"
description="Return page sections from one provider to show Users and Teams under the same @ trigger."
>
<MentionsInput
value={value}
onMentionsChange={onMentionsChange}
className="mentions"
classNames={groupedClassNames}
placeholder="Type '@a', '@team', or '@platform'"
a11ySuggestionsListLabel="Grouped people and team suggestions"
>
<Mention<DirectoryExtra>
trigger="@"
data={fetchGroupedDirectory}
debounceMs={120}
displayTransform={(id, display) => `@${display ?? String(id)}`}
className={mentionPillClass}
/>
</MentionsInput>
</ExampleCard>
)
}
2 changes: 2 additions & 0 deletions demo/src/examples/mentionsClassNames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export const multilineMentionsClassNames: MentionsInputClassNames = {
suggestionsStatus:
'px-4 py-2.5 text-left text-sm data-[status-type=empty]:text-slate-600 data-[status-type=error]:text-rose-600',
suggestionsList: baseSuggestionsList,
suggestionSection: 'bg-slate-50/80 px-4 py-2 text-xs font-semibold text-slate-500',
suggestionSectionLabel: 'block',
suggestionItem: baseSuggestionItem,
suggestionItemFocused: baseSuggestionItemFocused,
suggestionDisplay: 'inline-flex items-center',
Expand Down
10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,9 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/browser-playwright": "^4.1.4",
"@vitest/browser-preview": "^4.1.4",
"@vitest/coverage-v8": "^4.1.4",
"@vitest/browser-playwright": "^4.1.5",
"@vitest/browser-preview": "^4.1.5",
"@vitest/coverage-v8": "^4.1.5",
"@vitest/eslint-plugin": "^1.6.16",
"@welldone-software/why-did-you-render": "^10.0.1",
"class-variance-authority": "^0.7.1",
Expand All @@ -154,7 +154,7 @@
"jscpd": "^4.0.9",
"jsdom": "^29.0.2",
"jsonc-eslint-parser": "^3.1.0",
"knip": "^6.5.0",
"knip": "^6.6.0",
"oxfmt": "^0.46.0",
"oxlint": "^1.61.0",
"oxlint-tsgolint": "^0.21.1",
Expand All @@ -169,7 +169,7 @@
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.0",
"vite": "^8.0.9",
"vitest": "^4.1.4"
"vitest": "^4.1.5"
},
"peerDependencies": {
"class-variance-authority": ">=0.6.0",
Expand Down
Loading
Loading