Skip to content

Commit e36a290

Browse files
authored
Merge pull request #78 from hbmartin/auto-resize-followup
Auto resize followup
2 parents 88daff4 + 2bc12a7 commit e36a290

3 files changed

Lines changed: 283 additions & 22 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "react-mentions-ts",
33
"private": false,
4-
"version": "5.4.1",
4+
"version": "5.4.2",
55
"description": "A React component that enables Facebook/Twitter-style @mentions and tagging in textarea inputs with full TypeScript support.",
66
"type": "module",
77
"main": "./dist/index.cjs",

src/MentionsInput.spec.tsx

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -629,6 +629,216 @@ describe('MentionsInput', () => {
629629
})
630630
})
631631

632+
describe('autoResize', () => {
633+
it('resizes the textarea to the scroll height after a controlled value update', () => {
634+
const onMentionsChange = jest.fn()
635+
const { rerender } = render(
636+
<MentionsInput autoResize value="short" onMentionsChange={onMentionsChange}>
637+
<Mention trigger="@" data={data} />
638+
</MentionsInput>
639+
)
640+
641+
const combobox = screen.getByRole('combobox')
642+
let scrollHeight = 0
643+
Object.defineProperty(combobox, 'scrollHeight', {
644+
configurable: true,
645+
get: () => scrollHeight,
646+
})
647+
648+
expect(combobox.style.height).not.toBe('64px')
649+
650+
scrollHeight = 64
651+
652+
rerender(
653+
<MentionsInput
654+
autoResize
655+
value="a slightly longer value"
656+
onMentionsChange={onMentionsChange}
657+
>
658+
<Mention trigger="@" data={data} />
659+
</MentionsInput>
660+
)
661+
662+
const computed = window.getComputedStyle(combobox)
663+
const borderTop = Number.parseFloat(computed.borderTopWidth || '0')
664+
const borderBottom = Number.parseFloat(computed.borderBottomWidth || '0')
665+
expect(Number.parseFloat(combobox.style.height)).toBe(scrollHeight + borderTop + borderBottom)
666+
expect(combobox.style.overflowY).toBe('hidden')
667+
})
668+
669+
it('updates the textarea height when autoResize toggles from false to true', () => {
670+
const onMentionsChange = jest.fn()
671+
const { rerender } = render(
672+
<MentionsInput autoResize={false} value="initial" onMentionsChange={onMentionsChange}>
673+
<Mention trigger="@" data={data} />
674+
</MentionsInput>
675+
)
676+
677+
const combobox = screen.getByRole('combobox')
678+
let scrollHeight = 0
679+
Object.defineProperty(combobox, 'scrollHeight', {
680+
configurable: true,
681+
get: () => scrollHeight,
682+
})
683+
684+
expect(combobox.style.height).toBe('')
685+
686+
scrollHeight = 88
687+
688+
rerender(
689+
<MentionsInput autoResize value="initial" onMentionsChange={onMentionsChange}>
690+
<Mention trigger="@" data={data} />
691+
</MentionsInput>
692+
)
693+
694+
const computed = window.getComputedStyle(combobox)
695+
const borderTop = Number.parseFloat(computed.borderTopWidth || '0')
696+
const borderBottom = Number.parseFloat(computed.borderBottomWidth || '0')
697+
expect(Number.parseFloat(combobox.style.height)).toBe(scrollHeight + borderTop + borderBottom)
698+
expect(combobox.style.overflowY).toBe('hidden')
699+
})
700+
701+
it('applies the latest scroll height after user input changes while autoResize is enabled', async () => {
702+
const ControlledInput = () => {
703+
const [value, setValue] = React.useState('short text')
704+
return (
705+
<MentionsInput
706+
autoResize
707+
value={value}
708+
onMentionsChange={({ value: nextValue }) => setValue(nextValue)}
709+
>
710+
<Mention trigger="@" data={data} />
711+
</MentionsInput>
712+
)
713+
}
714+
715+
render(<ControlledInput />)
716+
717+
const combobox = screen.getByRole('combobox')
718+
let scrollHeight = 0
719+
Object.defineProperty(combobox, 'scrollHeight', {
720+
configurable: true,
721+
get: () => scrollHeight,
722+
})
723+
724+
const updatedValue = `${combobox.value} that is now longer`
725+
combobox.focus()
726+
combobox.setSelectionRange(combobox.value.length, combobox.value.length)
727+
728+
scrollHeight = 150
729+
730+
await act(async () => {
731+
fireEvent.change(combobox, {
732+
target: {
733+
value: updatedValue,
734+
selectionStart: updatedValue.length,
735+
selectionEnd: updatedValue.length,
736+
},
737+
})
738+
})
739+
740+
await waitFor(() => {
741+
const computed = window.getComputedStyle(combobox)
742+
const borderTop = Number.parseFloat(computed.borderTopWidth || '0')
743+
const borderBottom = Number.parseFloat(computed.borderBottomWidth || '0')
744+
expect(Number.parseFloat(combobox.style.height)).toBe(
745+
scrollHeight + borderTop + borderBottom
746+
)
747+
expect(combobox.style.overflowY).toBe('hidden')
748+
})
749+
})
750+
751+
it('skips resizing when rendering a single-line input', () => {
752+
render(
753+
<MentionsInput autoResize singleLine value="single line">
754+
<Mention trigger="@" data={data} />
755+
</MentionsInput>
756+
)
757+
758+
const input = screen.getByRole('combobox')
759+
expect(input.tagName).toBe('INPUT')
760+
expect(input.style.height).toBe('')
761+
expect(input.style.overflowY).toBe('')
762+
})
763+
764+
it('adds border widths to the measured height', () => {
765+
const onMentionsChange = jest.fn()
766+
const getComputedStyleSpy = jest.spyOn(window, 'getComputedStyle').mockReturnValue({
767+
borderTopWidth: '4px',
768+
borderBottomWidth: '6px',
769+
} as unknown as CSSStyleDeclaration)
770+
771+
const { rerender } = render(
772+
<MentionsInput autoResize value="initial" onMentionsChange={onMentionsChange}>
773+
<Mention trigger="@" data={data} />
774+
</MentionsInput>
775+
)
776+
777+
const textarea = screen.getByRole('combobox')
778+
let scrollHeight = 0
779+
Object.defineProperty(textarea, 'scrollHeight', {
780+
configurable: true,
781+
get: () => scrollHeight,
782+
})
783+
784+
scrollHeight = 90
785+
786+
rerender(
787+
<MentionsInput
788+
autoResize
789+
value="initial content extended"
790+
onMentionsChange={onMentionsChange}
791+
>
792+
<Mention trigger="@" data={data} />
793+
</MentionsInput>
794+
)
795+
796+
expect(textarea.style.height).toBe('100px')
797+
expect(textarea.style.overflowY).toBe('hidden')
798+
799+
getComputedStyleSpy.mockRestore()
800+
})
801+
802+
it('clears inline sizing when autoResize is disabled', () => {
803+
const onMentionsChange = jest.fn()
804+
const { rerender } = render(
805+
<MentionsInput autoResize value="draft" onMentionsChange={onMentionsChange}>
806+
<Mention trigger="@" data={data} />
807+
</MentionsInput>
808+
)
809+
810+
const textarea = screen.getByRole('combobox')
811+
let scrollHeight = 0
812+
Object.defineProperty(textarea, 'scrollHeight', {
813+
configurable: true,
814+
get: () => scrollHeight,
815+
})
816+
817+
scrollHeight = 72
818+
819+
rerender(
820+
<MentionsInput autoResize value="draft updated" onMentionsChange={onMentionsChange}>
821+
<Mention trigger="@" data={data} />
822+
</MentionsInput>
823+
)
824+
825+
const computed = window.getComputedStyle(textarea)
826+
const borderTop = Number.parseFloat(computed.borderTopWidth || '0')
827+
const borderBottom = Number.parseFloat(computed.borderBottomWidth || '0')
828+
expect(Number.parseFloat(textarea.style.height)).toBe(scrollHeight + borderTop + borderBottom)
829+
expect(textarea.style.overflowY).toBe('hidden')
830+
831+
rerender(
832+
<MentionsInput autoResize={false} value="draft updated" onMentionsChange={onMentionsChange}>
833+
<Mention trigger="@" data={data} />
834+
</MentionsInput>
835+
)
836+
837+
expect(textarea.style.height).toBe('')
838+
expect(textarea.style.overflowY).toBe('')
839+
})
840+
})
841+
632842
describe('custom cut/copy/paste', () => {
633843
const plainTextValue = "Hi First, \n\nlet's add Second to the conversation."
634844
const value = "Hi @[First](first), \n\nlet's add @[Second](second) to the conversation."

src/MentionsInput.tsx

Lines changed: 72 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,19 @@ class MentionsInput<
244244
private _pendingHighlighterRecompute = false
245245
private _didUnmount = false
246246
private _scrollSyncFrame: number | null = null
247+
private _autoResizeFrame: number | null = null
248+
249+
private cancelScheduledFrame(frameKey: '_scrollSyncFrame' | '_autoResizeFrame'): void {
250+
const frame = this[frameKey]
251+
if (
252+
frame !== null &&
253+
globalThis.window !== undefined &&
254+
typeof globalThis.cancelAnimationFrame === 'function'
255+
) {
256+
globalThis.cancelAnimationFrame(frame)
257+
this[frameKey] = null
258+
}
259+
}
247260

248261
private getSlotClassName(slot: keyof MentionsInputClassNames, baseClass: string) {
249262
const { classNames } = this.props
@@ -383,9 +396,7 @@ class MentionsInput<
383396

384397
this.updateSuggestionsPosition()
385398

386-
if (this.props.autoResize) {
387-
this.resetTextareaHeight()
388-
}
399+
this.resetTextareaHeight()
389400
}
390401

391402
// eslint-disable-next-line code-complete/low-function-cohesion
@@ -420,7 +431,7 @@ class MentionsInput<
420431
const configChanged = this.state.config !== prevState.config
421432
const valueChanged = currentValue !== previousValue || configChanged
422433

423-
if (this.props.autoResize && (valueChanged || prevProps.autoResize !== this.props.autoResize)) {
434+
if (valueChanged || prevProps.autoResize !== this.props.autoResize) {
424435
this.resetTextareaHeight()
425436
}
426437

@@ -487,10 +498,8 @@ class MentionsInput<
487498
document.removeEventListener('paste', this.handlePaste)
488499
document.removeEventListener('scroll', this.handleDocumentScroll, true)
489500
document.removeEventListener('selectionchange', this.handleDocumentSelectionChange)
490-
if (this._scrollSyncFrame !== null && globalThis.window !== undefined) {
491-
globalThis.cancelAnimationFrame(this._scrollSyncFrame)
492-
this._scrollSyncFrame = null
493-
}
501+
this.cancelScheduledFrame('_scrollSyncFrame')
502+
this.cancelScheduledFrame('_autoResizeFrame')
494503
this._pendingHighlighterRecompute = false
495504
this._didUnmount = true
496505
}
@@ -635,22 +644,68 @@ class MentionsInput<
635644
} else if (inputRef) {
636645
;(inputRef as React.RefObject<InputElement | null>).current = el
637646
}
638-
639-
if (this.props.autoResize) {
640-
this.resetTextareaHeight()
641-
}
642647
}
643648

644649
private readonly resetTextareaHeight = (): void => {
645-
const input = this.inputElement
646-
if (!input) {
650+
const hasTextarea =
651+
typeof HTMLTextAreaElement !== 'undefined' && this.inputElement instanceof HTMLTextAreaElement
652+
653+
// When disabled or in single-line mode, clear any previously applied inline sizing.
654+
if (this.props.singleLine === true || this.props.autoResize !== true) {
655+
if (hasTextarea) {
656+
this.inputElement!.style.height = ''
657+
this.inputElement!.style.overflowY = ''
658+
}
659+
if (this._autoResizeFrame !== null && typeof globalThis.cancelAnimationFrame === 'function') {
660+
globalThis.cancelAnimationFrame(this._autoResizeFrame)
661+
this._autoResizeFrame = null
662+
}
663+
return
664+
}
665+
666+
const measure = () => {
667+
const element = this.inputElement
668+
if (
669+
!element ||
670+
typeof HTMLTextAreaElement === 'undefined' ||
671+
!(element instanceof HTMLTextAreaElement)
672+
) {
673+
return
674+
}
675+
676+
element.style.height = 'auto'
677+
element.style.overflowY = 'hidden'
678+
679+
let borderAdjustment = 0
680+
if (globalThis.window !== undefined && typeof globalThis.getComputedStyle === 'function') {
681+
const computed = globalThis.getComputedStyle(element)
682+
const parse = (value: string | null | undefined) =>
683+
value ? Number.parseFloat(value) || 0 : 0
684+
borderAdjustment = parse(computed.borderTopWidth) + parse(computed.borderBottomWidth)
685+
}
686+
687+
const nextHeight = element.scrollHeight + borderAdjustment
688+
element.style.height = `${nextHeight}px`
689+
}
690+
691+
measure()
692+
693+
if (
694+
!hasTextarea ||
695+
globalThis.window === undefined ||
696+
typeof globalThis.requestAnimationFrame !== 'function'
697+
) {
647698
return
648699
}
649700

650-
if (typeof HTMLTextAreaElement !== 'undefined' && input instanceof HTMLTextAreaElement) {
651-
input.style.height = 'auto'
652-
input.style.height = `${input.scrollHeight}px`
701+
if (this._autoResizeFrame !== null && typeof globalThis.cancelAnimationFrame === 'function') {
702+
globalThis.cancelAnimationFrame(this._autoResizeFrame)
653703
}
704+
705+
this._autoResizeFrame = globalThis.requestAnimationFrame(() => {
706+
this._autoResizeFrame = null
707+
measure()
708+
})
654709
}
655710

656711
setSuggestionsElement = (el: HTMLDivElement | null) => {
@@ -1338,10 +1393,6 @@ class MentionsInput<
13381393
)
13391394

13401395
this.props.onChange?.(ev)
1341-
1342-
if (this.props.autoResize) {
1343-
this.resetTextareaHeight()
1344-
}
13451396
}
13461397

13471398
// Handle input element's select event

0 commit comments

Comments
 (0)