Skip to content
Draft
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
3 changes: 3 additions & 0 deletions .attw.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"excludeEntrypoints": ["styles/tailwind.css"]
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

In AreTheTypesWrong .attw.json, should excludeEntrypointsvalues for subpath exports use the exact export key (e.g.,"./styles/tailwind.css") or omit "./"(e.g.,"styles/tailwind.css")?

💡 Result:

In AreTheTypesWrong's .attw.json, excludeEntrypoints values for subpath exports should use the exact export key including the "./" prefix, such as "./styles/tailwind.css". The official CLI documentation and example configurations consistently show entrypoint paths with the ./ prefix. For example: - CLI usage: attw --pack . --exclude-entrypoints styles.css comments explain this excludes "./styles.css". - .attw.json example: "excludeEntrypoints": ["./internal"]. - --entrypoints . one two three maps to [".", "./one", "./two", "./three"]. Subpath exports in package.json are defined with ./ (e.g., "./styles/tailwind.css": "./dist/styles/tailwind.css"), and attw discovers/matches them as full subpaths from the package root, requiring the ./ prefix in excludeEntrypoints for exact matching.

Citations:


Use "./styles/tailwind.css" in excludeEntrypoints.

The current value "styles/tailwind.css" is missing the ./ prefix. AreTheTypesWrong requires excludeEntrypoints values to match the exact export key format, which includes the ./ prefix. Update to "./styles/tailwind.css".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.attw.json at line 2, Update the excludeEntrypoints entry to use the exact
export key format by adding the missing "./" prefix: change the string in the
excludeEntrypoints array from "styles/tailwind.css" to "./styles/tailwind.css"
so it matches AreTheTypesWrong's expected keys (refer to the excludeEntrypoints
property in .attw.json).

}
24 changes: 21 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ yarn add react-mentions-ts
pnpm add react-mentions-ts
```

React Mentions TS uses peer dependencies for its styling helpers and React runtime. Ensure these are installed in your application (skip any you already have):
The default `react-mentions-ts` entry is Tailwind-ready and uses optional styling peer dependencies for class composition. Install them when using the default styled entry:

```bash
# npm
Expand All @@ -53,6 +53,12 @@ pnpm add class-variance-authority clsx react react-dom tailwind-merge

Check `package.json` for the latest peer dependency version ranges.

If you want the smaller dependency-free rendering path, import from `react-mentions-ts/core` instead. The core entry only needs the React peers and leaves all visual styling to your app:

```tsx
import { MentionsInput, Mention } from 'react-mentions-ts/core'
```

## 🚀 Quick Start

### Add a MentionsInput with Mention children
Expand Down Expand Up @@ -382,7 +388,7 @@ No dynamic imports or `next/dynamic` wrappers are needed.

## 🎨 Styling

React Mentions ships its markup with **Tailwind utility classes**. Consumers should have Tailwind configured in their application build so these classes compile to real CSS. If you do not use Tailwind you can still provide your own styles via `className`, CSS modules, or inline styles.
The default `react-mentions-ts` entry ships its markup with **Tailwind utility classes**. Consumers should have Tailwind configured in their application build so these classes compile to real CSS. If you do not use Tailwind, import from `react-mentions-ts/core` and provide your own styles via `className`, CSS modules, or inline styles.

### Tailwind CSS

Expand All @@ -403,10 +409,22 @@ module.exports = {
@import 'react-mentions-ts/styles/tailwind.css';
```

The optional helper `react-mentions-ts/styles/tailwind.css` only declares an `@source “../dist”;` directive so Tailwind v4 can detect the library's utility classes inside `node_modules/react-mentions-ts/dist`. Including it keeps your Tailwind config clean and avoids adding explicit `content` globs for the package.
The optional helper `react-mentions-ts/styles/tailwind.css` is for the default styled entry. It only declares an `@source “../dist”;` directive so Tailwind v4 can detect the library's utility classes inside `node_modules/react-mentions-ts/dist`. Including it keeps your Tailwind config clean and avoids adding explicit `content` globs for the package.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use plain quotes in the @source example.

The inline directive currently shows smart quotes around ../dist, so a copy/paste into CSS will be invalid.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` at line 412, Replace the smart quotes in the `@source` directive
example in README.md for the optional helper
(react-mentions-ts/styles/tailwind.css) with plain ASCII double quotes so the
snippet reads `@source` "../dist";; locate the example showing the `@source`
“../dist” directive and update the quotation characters to plain quotes to
ensure valid CSS when copied.


If you are still on Tailwind v3, add `./node_modules/react-mentions-ts/dist/**/*.{js,jsx,ts,tsx}` to the `content` array instead of importing the helper file.

### Core Entry

Use `react-mentions-ts/core` when you want the behavior without Tailwind helper dependencies:

```tsx
import { MentionsInput, Mention } from 'react-mentions-ts/core'
```

The core entry exposes the same primary components and types as the default entry. It keeps the structural inline styles required for caret measurement, highlighter alignment, suggestions positioning, and single-line/multi-line behavior, but it does not provide visual Tailwind defaults.

The only intentional class behavior difference is merging: the styled root entry collapses conflicting Tailwind utility classes with `tailwind-merge`; the core entry concatenates classes as provided.

### CSS Class Names

Assign a `className` prop to `MentionsInput`. All DOM nodes will receive derived class names:
Expand Down
35 changes: 33 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
{
"name": "react-mentions-ts",
"private": false,
"version": "6.0.0-next.0",
"version": "6.0.0-next.1",
"description": "A React component that enables Facebook/Twitter-style @mentions and tagging in textarea inputs with full TypeScript support.",
"type": "module",
"packageManager": "pnpm@10.33.0",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.cts",
"typesVersions": {
"*": {
"core": [
"./dist/core.d.cts"
]
}
},
"exports": {
".": {
"import": {
Expand All @@ -19,6 +26,17 @@
"default": "./dist/index.cjs"
}
},
"./core": {
"import": {
"types": "./dist/core.d.ts",
"default": "./dist/core.js"
},
"require": {
"types": "./dist/core.d.cts",
"default": "./dist/core.cjs"
}
},
"./styles/tailwind.css": "./styles/tailwind.css",
"./package.json": "./package.json"
},
"files": [
Expand All @@ -29,7 +47,9 @@
"LICENSE",
"package.json"
],
"sideEffects": false,
"sideEffects": [
"./styles/tailwind.css"
],
"scripts": {
"clean": "rimraf dist",
"build": "pnpm clean && tsdown --config tsdown.config.ts",
Expand Down Expand Up @@ -152,6 +172,17 @@
"react-dom": ">=19.0.0",
"tailwind-merge": ">=3.0.0"
},
"peerDependenciesMeta": {
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Do not mark class-variance-authority, clsx, and tailwind-merge as optional peers while the default root export still requires them.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At package.json, line 175:

<comment>Do not mark `class-variance-authority`, `clsx`, and `tailwind-merge` as optional peers while the default root export still requires them.</comment>

<file context>
@@ -152,6 +172,17 @@
     "react-dom": ">=19.0.0",
     "tailwind-merge": ">=3.0.0"
   },
+  "peerDependenciesMeta": {
+    "class-variance-authority": {
+      "optional": true
</file context>
Fix with Cubic

"class-variance-authority": {
"optional": true
},
"clsx": {
"optional": true
},
"tailwind-merge": {
"optional": true
}
},
Comment on lines +175 to +185
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n package.json | head -200

Repository: hbmartin/react-mentions-ts

Length of output: 7227


🏁 Script executed:

# Check the exports structure and dependencies
cat package.json | jq '.exports, .dependencies, .peerDependencies, .peerDependenciesMeta' 2>/dev/null || cat package.json | grep -A 50 '"exports"' | head -60

Repository: hbmartin/react-mentions-ts

Length of output: 909


🏁 Script executed:

# Find the main entry point and check its imports
git ls-files | grep -E '(src/|lib/)' | head -20

Repository: hbmartin/react-mentions-ts

Length of output: 807


🏁 Script executed:

# Look for the styled.ts file mentioned in the comment
fd -t f 'styled\.ts' 2>/dev/null || find . -name 'styled.ts' -type f 2>/dev/null

Repository: hbmartin/react-mentions-ts

Length of output: 90


🏁 Script executed:

cat -n src/styles/styled.ts

Repository: hbmartin/react-mentions-ts

Length of output: 4273


🏁 Script executed:

# Check the main index entry point
fd -t f 'index\.ts' -path 'src/*' | head -5

Repository: hbmartin/react-mentions-ts

Length of output: 238


🏁 Script executed:

# Check if there's a core.ts file
fd -t f 'core\.ts' -path 'src/*' | head -5

Repository: hbmartin/react-mentions-ts

Length of output: 238


🏁 Script executed:

# Check imports in the main index file
cat src/index.ts 2>/dev/null | head -50

Repository: hbmartin/react-mentions-ts

Length of output: 821


🏁 Script executed:

# Search for imports of styled.ts
rg -n "from.*styled\'" --type ts --type tsx

Repository: hbmartin/react-mentions-ts

Length of output: 162


🏁 Script executed:

# Check MentionsInput imports
rg -n "import.*styled" src/MentionsInput.tsx -A 2 -B 2

Repository: hbmartin/react-mentions-ts

Length of output: 209


🏁 Script executed:

# Look for core.ts file
fd -type f 'core.ts'

Repository: hbmartin/react-mentions-ts

Length of output: 240


🏁 Script executed:

# Check what gets exported from the main components
rg -n "styledStyles\|styled\|tailwindMerge\|cva" src/MentionsInput.tsx | head -20

Repository: hbmartin/react-mentions-ts

Length of output: 52


🏁 Script executed:

# Check what tailwindMergeClassNames imports
cat -n src/styles/tailwindMergeClassNames.ts

Repository: hbmartin/react-mentions-ts

Length of output: 432


🏁 Script executed:

# Search for imports of styledStyles
rg -n "styledStyles" src/

Repository: hbmartin/react-mentions-ts

Length of output: 1195


🏁 Script executed:

# Check if there's a core entry point file
fd 'core\.ts$' src/

Repository: hbmartin/react-mentions-ts

Length of output: 100


🏁 Script executed:

# Look for what the build system exports for core and main
cat tsdown.config.ts 2>/dev/null || find . -name "tsdown.config*" -type f

Repository: hbmartin/react-mentions-ts

Length of output: 504


🏁 Script executed:

# Check what's in the core entry point
cat -n src/core.ts

Repository: hbmartin/react-mentions-ts

Length of output: 992


🏁 Script executed:

# Check what's in styles/core.ts
cat -n src/styles/core.ts

Repository: hbmartin/react-mentions-ts

Length of output: 5730


🏁 Script executed:

# Check if core/MentionsInput imports styled or core styles
cat -n src/core/MentionsInput.tsx | head -50

Repository: hbmartin/react-mentions-ts

Length of output: 402


🏁 Script executed:

# List what's in the core directory
find src/core -type f -name "*.tsx" | head -20

Repository: hbmartin/react-mentions-ts

Length of output: 117


🏁 Script executed:

# Check if any core components import the styled.ts (which uses optional deps)
rg -l "from.*styles/styled" src/core/

Repository: hbmartin/react-mentions-ts

Length of output: 52


🏁 Script executed:

# Verify if default MentionsInput uses styled
head -20 src/MentionsInput.tsx

Repository: hbmartin/react-mentions-ts

Length of output: 350


Don't make the styled runtime helpers optional peers.

The default entry imports class-variance-authority, clsx, and tailwind-merge directly through src/styles/styled.ts, making them hard runtime dependencies. The react-mentions-ts/core entry uses src/styles/core.ts instead, which has no dependency on these packages and can safely treat them as optional. However, marking all three as optional peer dependencies removes the install-time signal for the default entry, creating a runtime risk for consumers whose package managers do not auto-install optional peers.

Either keep these as required peer dependencies for the default entry, or split the styled entry behind a separate package/export that explicitly declares the dependency.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@package.json` around lines 175 - 185, The peerDependenciesMeta currently
marks class-variance-authority, clsx, and tailwind-merge as optional which
breaks the default entry that imports src/styles/styled.ts; change package.json
so those three remain required peerDependencies for the default export (i.e.,
remove them from peerDependenciesMeta optional list) OR refactor the styled
runtime into its own export/package that imports src/styles/styled.ts and
declare those three as peers only on that new export; keep
react-mentions-ts/core referencing src/styles/core.ts as optional-peer-safe.

"engines": {
"node": ">=22"
},
Expand Down
268 changes: 7 additions & 261 deletions src/Highlighter.tsx
Original file line number Diff line number Diff line change
@@ -1,263 +1,9 @@
import React, { useLayoutEffect, useMemo, useState } from 'react'
import type { CSSProperties } from 'react'
import { cva } from 'class-variance-authority'
import { iterateMentionsMarkup, mapPlainTextIndex, isNumber, cn } from './utils'
import readConfigFromChildren, { collectMentionElements } from './utils/readConfigFromChildren'
import { useEventCallback } from './utils/useEventCallback'
import type {
CaretCoordinates,
MentionChildConfig,
MentionComponentProps,
MentionSelectionState,
} from './types'
import HighlighterBase from './HighlighterBase'
import type { HighlighterProps } from './HighlighterBase'
import styledStyles from './styles/styled'

const generateComponentKey = (usedKeys: Record<string, number>, id: string) => {
if (Object.hasOwn(usedKeys, id)) {
usedKeys[id] += 1
} else {
usedKeys[id] = 0
}
return `${id}_${usedKeys[id].toString()}`
export default function Highlighter<
Extra extends Record<string, unknown> = Record<string, unknown>,
>(props: Readonly<Omit<HighlighterProps<Extra>, 'styles'>>) {
return <HighlighterBase {...props} styles={styledStyles.highlighter} />
}

interface HighlighterProps<Extra extends Record<string, unknown> = Record<string, unknown>> {
readonly selectionStart: number | null
readonly selectionEnd: number | null
readonly value?: string
readonly onCaretPositionChange: (position: CaretCoordinates) => void
readonly containerRef?: (node: HTMLDivElement | null) => void
readonly children: React.ReactNode
readonly mentionChildren?: React.ReactElement<MentionComponentProps<Extra>>[]
readonly config?: MentionChildConfig<Extra>[]
readonly singleLine: boolean
readonly className?: string
readonly substringClassName?: string
readonly caretClassName?: string
readonly recomputeVersion?: number
readonly mentionSelectionMap?: Record<string, MentionSelectionState>
}

// Note: singleLine intentionally overrides whitespace/break behavior
const highlighterStyles = cva(
'box-border w-full overflow-hidden text-start text-transparent pointer-events-none [font-family:inherit] [font-size:inherit] [line-height:inherit]',
{
variants: {
singleLine: {
true: 'whitespace-pre break-normal',
false: 'whitespace-pre-wrap break-words',
},
},
}
)

const substringStyles = 'text-transparent inline [white-space:inherit]'
const caretStyles = 'relative inline-block h-0 w-0 align-baseline'
const singleLineContentWrapperStyle: CSSProperties = {
display: 'inline-block',
whiteSpace: 'inherit',
width: 'max-content',
minWidth: '100%',
}

function Highlighter<Extra extends Record<string, unknown> = Record<string, unknown>>({
selectionStart,
selectionEnd,
value = '',
onCaretPositionChange,
containerRef,
children,
mentionChildren: mentionChildrenProp,
config: configProp,
singleLine,
className,
substringClassName,
caretClassName,
recomputeVersion,
mentionSelectionMap,
}: HighlighterProps<Extra>) {
const [position, setPosition] = useState<CaretCoordinates | null>(null)
const [caretElement, setCaretElement] = useState<HTMLSpanElement | null>(null)

const updatePosition = useEventCallback((offsetLeft: number, offsetTop: number) => {
if (position?.left === offsetLeft && position.top === offsetTop) {
return
}

const newPosition: CaretCoordinates = { left: offsetLeft, top: offsetTop }
setPosition(newPosition)
onCaretPositionChange(newPosition)
})

// Ensure caret position updates whenever content/selection affects layout.
useLayoutEffect(() => {
if (caretElement === null) {
return undefined
}

const measure = () => {
const offsetLeft = caretElement.offsetLeft
const offsetTop =
caretElement.previousElementSibling === null
? caretElement.offsetTop
: (caretElement.previousElementSibling as HTMLSpanElement).offsetTop +
(caretElement.previousElementSibling as HTMLSpanElement).offsetHeight

updatePosition(offsetLeft, offsetTop)
}

const rafId =
typeof globalThis.requestAnimationFrame === 'function'
? globalThis.requestAnimationFrame(measure)
: undefined

if (rafId === undefined) {
measure()
}

return () => {
if (rafId !== undefined && typeof globalThis.cancelAnimationFrame === 'function') {
globalThis.cancelAnimationFrame(rafId)
}
}
// value/selection/singleLine impact layout/position
}, [
caretElement,
recomputeVersion,
selectionEnd,
selectionStart,
singleLine,
updatePosition,
value,
])

const mentionChildren = useMemo(
() => mentionChildrenProp ?? collectMentionElements(children),
[children, mentionChildrenProp]
)
const config: MentionChildConfig<Extra>[] = useMemo(
() => configProp ?? readConfigFromChildren<Extra>(mentionChildren),
[configProp, mentionChildren]
)
let caretPositionInMarkup: number | null | undefined

const rootClassName = cn(highlighterStyles({ singleLine }), className)
const substringClass = cn(substringStyles, substringClassName)
const caretClass = cn(caretStyles, caretClassName)

if (selectionEnd === selectionStart) {
caretPositionInMarkup = mapPlainTextIndex(value, config, selectionStart, 'START') as
| number
| undefined
}
const selectionMap = mentionSelectionMap ?? {}

const resultComponents: React.ReactNode[] = []
const componentKeys: Record<string, number> = {}
let components: React.ReactNode[] = resultComponents
let substringComponentKey = 0

const renderSubstring = (substringValue: string, key: number) => (
// set substring span to hidden, so that Emojis are not shown double in Mobile Safari
<span className={substringClass} key={key}>
{substringValue}
</span>
)

const getMentionComponentForMatch = (
id: string,
display: string,
mentionChildIndex: number,
key: string,
plainTextIndex: number
) => {
const selectionKey = `${mentionChildIndex}:${plainTextIndex}`
const selectionState = selectionMap[selectionKey]
const props = { id, display, key, selectionState }
const child = mentionChildren[mentionChildIndex] as React.ReactElement<MentionComponentProps>
return React.cloneElement(child, props)
}

const renderCaretMarker = () => (
<span
className={caretClass}
data-mentions-caret
ref={setCaretElement}
key="caret"
aria-hidden="true"
/>
)

const textIteratee = (substr: string, index: number, _substrPlainTextIndex: number) => {
if (
isNumber(caretPositionInMarkup) &&
caretPositionInMarkup >= index &&
caretPositionInMarkup <= index + substr.length
) {
const splitIndex = caretPositionInMarkup - index
// Before the reassignment, components still points at resultComponents, so the push stores
// the substring that comes before the caret in the final output.
components.push(renderSubstring(substr.slice(0, splitIndex), substringComponentKey))
substringComponentKey += 1
// Reassigning component just switches the working array to a fresh list
// for the text after the caret; later we splice that array back in
// Without the initial push, the prefix would not land in resultComponents.
components = [renderSubstring(substr.slice(splitIndex), substringComponentKey)]
} else {
components.push(renderSubstring(substr, substringComponentKey))
}
substringComponentKey += 1
}

const mentionIteratee = (
_markup: string,
_index: number,
plainTextIndex: number,
id: string,
display: string,
mentionChildIndex: number
) => {
const key = generateComponentKey(componentKeys, id)
components.push(
getMentionComponentForMatch(id, display, mentionChildIndex, key, plainTextIndex)
)
}

iterateMentionsMarkup(value, config, mentionIteratee, textIteratee)

// append a span containing a space, to ensure the last text line has the correct height
components.push(' ')

if (components !== resultComponents) {
resultComponents.push(renderCaretMarker(), ...components)
}
const content = singleLine ? (
<div style={singleLineContentWrapperStyle}>{resultComponents}</div>
) : (
resultComponents
)

return (
<div
className={rootClassName}
data-slot="highlighter"
data-single-line={singleLine ? 'true' : undefined}
data-multi-line={singleLine ? undefined : 'true'}
style={HIGHLIGHTER_OVERLAY_STYLE}
ref={containerRef}
>
{content}
</div>
)
}

const HIGHLIGHTER_OVERLAY_STYLE: CSSProperties = {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
pointerEvents: 'none',
zIndex: 0,
}

export default Highlighter
Loading
Loading