Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
2 changes: 2 additions & 0 deletions demo/src/examples/Examples.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import AllowSpaceInQuery from './AllowSpaceInQuery'
import AlphabetRegexTrigger from './AlphabetRegexTrigger'
import MentionSelection from './MentionSelection'
import AutoResize from './AutoResize'
import LeftAnchored from './LeftAnchored'

const users = [
{
Expand Down Expand Up @@ -91,6 +92,7 @@ export default function Examples() {
<Emojis data={users} onAdd={(addParams) => console.log('onAdd', addParams)} />
<SuggestionPortal data={users} />
<CustomSuggestionsContainer data={users} />
<LeftAnchored data={users} />
</div>
)
}
31 changes: 31 additions & 0 deletions demo/src/examples/LeftAnchored.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import React, { useState } from 'react'

import { Mention, MentionsInput } from '../../../src'
import type { MentionDataItem, MentionsInputChangeEvent } from '../../../src'
import ExampleCard from './ExampleCard'

export default function LeftAnchored({ data }: { data: MentionDataItem[] }) {
const [value, setValue] = useState('')

const onMentionsChange = ({ value: nextValue }: MentionsInputChangeEvent) => {
setValue(nextValue)
}

return (
<ExampleCard
title="Left anchored suggestions"
description="Pop the overlay from the input edge instead of the caret, ideal for wide inputs."
>
<MentionsInput
anchorMode="left"
value={value}
onMentionsChange={onMentionsChange}
className="mentions"
placeholder="Start typing '@' to mention"
a11ySuggestionsListLabel="Suggested mentions"
>
<Mention data={data} />
</MentionsInput>
</ExampleCard>
)
}
16 changes: 8 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "react-mentions-ts",
"private": false,
"version": "5.4.3",
"version": "5.4.4",
"description": "A React component that enables Facebook/Twitter-style @mentions and tagging in textarea inputs with full TypeScript support.",
"type": "module",
"main": "./dist/index.cjs",
Expand Down Expand Up @@ -97,19 +97,19 @@
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/jest": "^30.0.0",
"@types/node": "^24.9.2",
"@types/node": "^24.10.0",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"eslint": "^9.38.0",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-code-complete": "^1.1.2",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jest": "^29.0.1",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-package-json": "^0.59.0",
"eslint-plugin-package-json": "^0.64.0",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-promise": "^7.2.1",
"eslint-plugin-react": "^7.37.5",
Expand All @@ -125,19 +125,19 @@
"jest-environment-jsdom": "^30.2.0",
"jiti": "^2.6.1",
"jsonc-eslint-parser": "^2.4.1",
"knip": "^5.66.4",
"knip": "^5.67.1",
"prettier": "^3.6.2",
"publint": "^0.3.15",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"rimraf": "^6.0.1",
"rimraf": "^6.1.0",
"tailwind-merge": "^3.3.1",
"ts-jest": "^29.4.5",
"ts-node": "^10.9.2",
"tsup": "^8.5.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.2",
"vite": "^7.1.12"
"typescript-eslint": "^8.46.3",
"vite": "^7.2.1"
},
"peerDependencies": {
"class-variance-authority": ">=0.6.0",
Expand Down
38 changes: 33 additions & 5 deletions src/MentionsInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
MentionSelectionState,
MentionChildConfig,
MentionsInputProps,
MentionsInputAnchorMode,
MentionsInputState,
MentionsInputClassNames,
MentionsInputChangeTrigger,
Expand All @@ -66,7 +67,7 @@
// eslint-disable-next-line @typescript-eslint/require-await
return async (query: string) =>
items.flatMap((item) => {
const index = getSubstringIndex(item.display || String(item.id), query, ignoreAccents)

Check warning on line 70 in src/MentionsInput.tsx

View workflow job for this annotation

GitHub Actions / build

Unexpected nullable string value in conditional. Please handle the nullish/empty cases explicitly
return index >= 0
? [
{
Expand Down Expand Up @@ -190,8 +191,9 @@
whiteSpace: 'nowrap',
}

const HANDLED_PROPS: Array<keyof MentionsInputProps<any>> = [

Check failure on line 194 in src/MentionsInput.tsx

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type
'singleLine',
'anchorMode',
'suggestionsPlacement',
'a11ySuggestionsListLabel',
'value',
Expand Down Expand Up @@ -223,6 +225,7 @@
} = {
singleLine: false,
autoResize: false,
anchorMode: 'caret',
suggestionsPlacement: 'below',
onKeyDown: () => null,
onSelect: () => null,
Expand Down Expand Up @@ -251,7 +254,7 @@
const frame = this[frameKey]
if (
frame !== null &&
globalThis.window !== undefined &&

Check failure on line 257 in src/MentionsInput.tsx

View workflow job for this annotation

GitHub Actions / build

Unnecessary conditional, the types have no overlap
typeof globalThis.cancelAnimationFrame === 'function'
) {
globalThis.cancelAnimationFrame(frame)
Expand Down Expand Up @@ -330,7 +333,7 @@
if (child.type !== Mention) {
throw new Error(
`MentionsInput only accepts Mention components as children. Found: ${
typeof child.type === 'string' ? child.type : child.type?.name || 'unknown component'

Check failure on line 336 in src/MentionsInput.tsx

View workflow job for this annotation

GitHub Actions / build

Unnecessary optional chain on a non-nullish value
}`
)
}
Expand Down Expand Up @@ -405,7 +408,7 @@
}

// eslint-disable-next-line code-complete/low-function-cohesion
componentDidUpdate(

Check failure on line 411 in src/MentionsInput.tsx

View workflow job for this annotation

GitHub Actions / build

Refactor this function to reduce its Cognitive Complexity from 18 to the 15 allowed
prevProps: MentionsInputProps<Extra>,
prevState: MentionsInputState<Extra>
): void {
Expand Down Expand Up @@ -517,7 +520,7 @@
this._didUnmount = true
}

render(): React.ReactNode {

Check failure on line 523 in src/MentionsInput.tsx

View workflow job for this annotation

GitHub Actions / build

Refactor this function to always return the same type
const { className, style, singleLine } = this.props
const rootClassName = cn(rootStyles(), className)
return (
Expand All @@ -542,12 +545,12 @@
}

// eslint-disable-next-line code-complete/low-function-cohesion
getInputProps = (): InputComponentProps => {

Check failure on line 548 in src/MentionsInput.tsx

View workflow job for this annotation

GitHub Actions / build

Refactor this function to reduce its Cognitive Complexity from 19 to the 15 allowed
const { readOnly, disabled, singleLine } = this.props

const passthroughProps = omit(
this.props,
HANDLED_PROPS as ReadonlyArray<keyof MentionsInputProps<any>>

Check failure on line 553 in src/MentionsInput.tsx

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type
) as Partial<InputComponentProps>

const { ...restPassthrough } = passthroughProps
Expand All @@ -557,18 +560,18 @@
const props: Record<string, unknown> = {
...restPassthrough,
className: baseClassName,
value: getPlainText(this.props.value ?? '', this.state.config),

Check failure on line 563 in src/MentionsInput.tsx

View workflow job for this annotation

GitHub Actions / build

Use destructured variables over properties
onScroll: this.updateHighlighterScroll,
'data-slot': 'input',
'data-single-line': singleLine ? 'true' : undefined,

Check warning on line 566 in src/MentionsInput.tsx

View workflow job for this annotation

GitHub Actions / build

Unexpected nullable boolean value in conditional. Please handle the nullish case explicitly
'data-multi-line': singleLine ? undefined : 'true',

Check warning on line 567 in src/MentionsInput.tsx

View workflow job for this annotation

GitHub Actions / build

Unexpected nullable boolean value in conditional. Please handle the nullish case explicitly
}

const inlineStyle: CSSProperties = {
background: 'transparent',
}

if (!singleLine && isMobileSafari) {

Check warning on line 574 in src/MentionsInput.tsx

View workflow job for this annotation

GitHub Actions / build

Unexpected nullable boolean value in conditional. Please handle the nullish case explicitly
inlineStyle.marginTop = 1
inlineStyle.marginLeft = -3
}
Expand All @@ -577,7 +580,7 @@
props.style = inlineStyle
}

if (!readOnly && !disabled) {

Check warning on line 583 in src/MentionsInput.tsx

View workflow job for this annotation

GitHub Actions / build

Unexpected nullable boolean value in conditional. Please handle the nullish case explicitly

Check warning on line 583 in src/MentionsInput.tsx

View workflow job for this annotation

GitHub Actions / build

Unexpected nullable boolean value in conditional. Please handle the nullish case explicitly
Object.assign(props, {
onChange: this.handleChange,
onSelect: this.handleSelect,
Expand Down Expand Up @@ -606,7 +609,7 @@
const existingDescribedBy =
typeof props['aria-describedby'] === 'string' ? props['aria-describedby'] : undefined
const describedBy = [existingDescribedBy, this.inlineAutocompleteLiveRegionId]
.filter((value): value is string => Boolean(value && value.trim().length > 0))

Check warning on line 612 in src/MentionsInput.tsx

View workflow job for this annotation

GitHub Actions / build

Unexpected nullable string value in conditional. Please handle the nullish/empty cases explicitly
.join(' ')
props['aria-describedby'] = describedBy || undefined
}
Expand All @@ -621,13 +624,13 @@

const control = CustomInput
? React.createElement(
CustomInput as React.ComponentType<any>,

Check failure on line 627 in src/MentionsInput.tsx

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type
{
ref: this.setInputRef,
...inputProps,
} as any

Check failure on line 631 in src/MentionsInput.tsx

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type
)
: singleLine

Check warning on line 633 in src/MentionsInput.tsx

View workflow job for this annotation

GitHub Actions / build

Unexpected nullable boolean value in conditional. Please handle the nullish case explicitly
? this.renderInput(inputProps)
: this.renderTextarea(inputProps)

Expand Down Expand Up @@ -693,7 +696,7 @@
if (globalThis.window !== undefined && typeof globalThis.getComputedStyle === 'function') {
const computed = globalThis.getComputedStyle(element)
const parse = (value: string | null | undefined) =>
value ? Number.parseFloat(value) || 0 : 0

Check warning on line 699 in src/MentionsInput.tsx

View workflow job for this annotation

GitHub Actions / build

Unexpected nullable string value in conditional. Please handle the nullish/empty cases explicitly
borderAdjustment = parse(computed.borderTopWidth) + parse(computed.borderBottomWidth)
}

Expand Down Expand Up @@ -1339,7 +1342,7 @@
if ('isComposing' in native && typeof native.isComposing === 'boolean') {
this._isComposing = native.isComposing
}
const value = this.props.value || ''

Check warning on line 1345 in src/MentionsInput.tsx

View workflow job for this annotation

GitHub Actions / build

Unexpected nullable string value in conditional. Please handle the nullish/empty cases explicitly

let newPlainTextValue = ev.target.value

Expand Down Expand Up @@ -1603,6 +1606,8 @@
updateSuggestionsPosition = (): void => {
const { caretPosition } = this.state
const { suggestionsPlacement = 'below' } = this.props
const anchorMode: MentionsInputAnchorMode = this.props.anchorMode ?? 'caret'
const anchorToLeft = anchorMode === 'left'
const resolvedPortalHost = this.resolvePortalHost()

const suggestions = this.suggestionsElement
Expand All @@ -1617,7 +1622,7 @@
const caretOffsetParentRect = highlighter.getBoundingClientRect()
const caretHeight = getComputedStyleLengthProp(highlighter, 'font-size')
const viewportRelative = {
left: caretOffsetParentRect.left + caretPosition.left,
left: caretOffsetParentRect.left + (anchorToLeft ? 0 : caretPosition.left),
top: caretOffsetParentRect.top + caretPosition.top + caretHeight,
}
const viewportHeight = Math.max(document.documentElement.clientHeight, window.innerHeight || 0)
Expand All @@ -1636,7 +1641,9 @@
left -= getComputedStyleLengthProp(suggestions, 'margin-left')
top -= getComputedStyleLengthProp(suggestions, 'margin-top')
// take into account highlighter/textinput scrolling:
left -= highlighter.scrollLeft
if (!anchorToLeft) {
left -= highlighter.scrollLeft
}
top -= highlighter.scrollTop
// guard for mentions suggestions list clipped by window edges
const maxLeft = Math.max(0, viewportWidth - width)
Expand All @@ -1655,10 +1662,12 @@
const containerWidth = container.offsetWidth
const width = Math.min(desiredWidth, containerWidth)
position.width = width
const left = caretPosition.left - highlighter.scrollLeft
const left = anchorToLeft ? 0 : caretPosition.left - highlighter.scrollLeft
const top = caretPosition.top - highlighter.scrollTop
// guard for mentions suggestions list clipped by right edge of window
if (left + width > containerWidth) {
if (anchorToLeft) {
position.left = 0
} else if (left + width > containerWidth) {
position.right = 0
} else {
position.left = left
Expand All @@ -1679,7 +1688,8 @@
position.left === this.state.suggestionsPosition.left &&
position.top === this.state.suggestionsPosition.top &&
position.position === this.state.suggestionsPosition.position &&
position.width === this.state.suggestionsPosition.width
position.width === this.state.suggestionsPosition.width &&
position.right === this.state.suggestionsPosition.right
) {
return
}
Expand Down Expand Up @@ -2076,6 +2086,24 @@
[observe, suggestions, updateSuggestions]
)

useLayoutEffect(() => {
if (typeof window === 'undefined') {
return undefined
}

const handleViewportChange = () => {
updateAll()
}

window.addEventListener('resize', handleViewportChange)
window.addEventListener('orientationchange', handleViewportChange)

return () => {
window.removeEventListener('resize', handleViewportChange)
window.removeEventListener('orientationchange', handleViewportChange)
}
}, [updateAll])

useLayoutEffect(() => {
if (!input) {
return undefined
Expand Down
2 changes: 1 addition & 1 deletion src/SuggestionsOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ interface SuggestionsOverlayProps<Extra extends Record<string, unknown> = Record
}

const overlayStyles = cva(
'z-[100] mt-3 w-full min-w-[16rem] overflow-hidden rounded-2xl border border-border bg-popover shadow-xl ring-1 ring-ring backdrop-blur supports-[backdrop-filter]:bg-popover/95'
'z-[100] mt-3 w-full min-w-[16rem] overflow-hidden rounded-xl border border-border bg-popover shadow-xl ring-1 ring-ring backdrop-blur supports-[backdrop-filter]:bg-popover/95'
)
const listStyles =
'm-0 max-h-64 list-none divide-y divide-border overflow-y-auto scroll-py-1 p-0 focus:outline-none'
Expand Down
3 changes: 3 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@ export interface CaretCoordinates {
left: number
}

export type MentionsInputAnchorMode = 'caret' | 'left'

export interface SuggestionsPosition {
position?: 'absolute' | 'fixed'
left?: number
Expand All @@ -194,6 +196,7 @@ export interface MentionsInputProps<Extra extends Record<string, unknown> = Reco
'children' | 'onChange' | 'value' | 'defaultValue' | 'style' | 'onBlur'
> {
a11ySuggestionsListLabel?: string
anchorMode?: MentionsInputAnchorMode
suggestionsPlacement?: 'auto' | 'above' | 'below'
customSuggestionsContainer?: (children: ReactElement) => ReactElement
disabled?: boolean
Expand Down
Loading