Skip to content

Commit 38012cd

Browse files
authored
Merge pull request #90 from hbmartin/left-anchor-mode-followup
additional tests and break import cycles
2 parents ec11fba + 417ca4a commit 38012cd

11 files changed

Lines changed: 117 additions & 47 deletions

src/Highlighter.tsx

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,8 @@
11
import React, { Children, useLayoutEffect, useMemo, useState } from 'react'
22
import type { CSSProperties } from 'react'
33
import { cva } from 'class-variance-authority'
4-
import {
5-
iterateMentionsMarkup,
6-
mapPlainTextIndex,
7-
readConfigFromChildren,
8-
isNumber,
9-
cn,
10-
} from './utils'
4+
import { iterateMentionsMarkup, mapPlainTextIndex, isNumber, cn } from './utils'
5+
import readConfigFromChildren from './utils/readConfigFromChildren'
116
import { useEffectEvent } from './utils/useEffectEvent'
127
import type {
138
CaretCoordinates,

src/Mention.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { CSSProperties } from 'react'
2-
import { cn } from './utils'
2+
import cn from './utils/cn'
33
import type { MentionComponentProps } from './types'
44

55
export interface MentionProps<Extra extends Record<string, unknown> = Record<string, unknown>>

src/MentionsInput.spec.tsx

Lines changed: 88 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,66 @@ describe('MentionsInput', () => {
8787
expect(input.tagName).toBe('INPUT')
8888
})
8989

90+
describe('single-line versus multi-line modes', () => {
91+
it('switches DOM structure and data attributes when toggling modes.', () => {
92+
const { container, rerender } = render(
93+
<MentionsInput value="">
94+
<Mention trigger="@" data={data} />
95+
</MentionsInput>
96+
)
97+
98+
const initialRoot = container.firstElementChild as HTMLElement
99+
expect(initialRoot).toHaveAttribute('data-multi-line', 'true')
100+
expect(initialRoot).not.toHaveAttribute('data-single-line')
101+
102+
let inputElement = container.querySelector('[data-slot="input"]') as HTMLElement
103+
expect(inputElement.tagName).toBe('TEXTAREA')
104+
expect(inputElement).toHaveAttribute('data-multi-line', 'true')
105+
expect(inputElement).not.toHaveAttribute('data-single-line')
106+
107+
rerender(
108+
<MentionsInput value="" singleLine>
109+
<Mention trigger="@" data={data} />
110+
</MentionsInput>
111+
)
112+
113+
const updatedRoot = container.firstElementChild as HTMLElement
114+
expect(updatedRoot).toHaveAttribute('data-single-line', 'true')
115+
expect(updatedRoot).not.toHaveAttribute('data-multi-line')
116+
117+
inputElement = container.querySelector('[data-slot="input"]') as HTMLElement
118+
expect(inputElement.tagName).toBe('INPUT')
119+
expect(inputElement).toHaveAttribute('data-single-line', 'true')
120+
expect(inputElement).not.toHaveAttribute('data-multi-line')
121+
})
122+
123+
it('keeps the highlighter whitespace handling in sync with the singleLine prop.', () => {
124+
const { container, rerender } = render(
125+
<MentionsInput value="Hello">
126+
<Mention trigger="@" data={data} />
127+
</MentionsInput>
128+
)
129+
130+
const control = container.querySelector('[data-slot="control"]') as HTMLElement
131+
const initialHighlighter = control.firstElementChild as HTMLElement
132+
expect(initialHighlighter).toHaveClass('whitespace-pre-wrap')
133+
expect(initialHighlighter).toHaveClass('break-words')
134+
expect(initialHighlighter).not.toHaveClass('break-normal')
135+
136+
rerender(
137+
<MentionsInput value="Hello" singleLine>
138+
<Mention trigger="@" data={data} />
139+
</MentionsInput>
140+
)
141+
142+
const updatedControl = container.querySelector('[data-slot="control"]') as HTMLElement
143+
const updatedHighlighter = updatedControl.firstElementChild as HTMLElement
144+
expect(updatedHighlighter).toHaveClass('whitespace-pre')
145+
expect(updatedHighlighter).toHaveClass('break-normal')
146+
expect(updatedHighlighter).not.toHaveClass('whitespace-pre-wrap')
147+
})
148+
})
149+
90150
describe('validation', () => {
91151
let consoleError: jest.SpyInstance
92152

@@ -661,7 +721,7 @@ describe('MentionsInput', () => {
661721
</MentionsInput>
662722
)
663723

664-
const computed = window.getComputedStyle(combobox)
724+
const computed = globalThis.getComputedStyle(combobox)
665725
const borderTop = Number.parseFloat(computed.borderTopWidth || '0')
666726
const borderBottom = Number.parseFloat(computed.borderBottomWidth || '0')
667727
expect(Number.parseFloat(combobox.style.height)).toBe(scrollHeight + borderTop + borderBottom)
@@ -693,7 +753,7 @@ describe('MentionsInput', () => {
693753
</MentionsInput>
694754
)
695755

696-
const computed = window.getComputedStyle(combobox)
756+
const computed = globalThis.getComputedStyle(combobox)
697757
const borderTop = Number.parseFloat(computed.borderTopWidth || '0')
698758
const borderBottom = Number.parseFloat(computed.borderBottomWidth || '0')
699759
expect(Number.parseFloat(combobox.style.height)).toBe(scrollHeight + borderTop + borderBottom)
@@ -740,7 +800,7 @@ describe('MentionsInput', () => {
740800
})
741801

742802
await waitFor(() => {
743-
const computed = window.getComputedStyle(combobox)
803+
const computed = globalThis.getComputedStyle(combobox)
744804
const borderTop = Number.parseFloat(computed.borderTopWidth || '0')
745805
const borderBottom = Number.parseFloat(computed.borderBottomWidth || '0')
746806
expect(Number.parseFloat(combobox.style.height)).toBe(
@@ -765,7 +825,7 @@ describe('MentionsInput', () => {
765825

766826
it('adds border widths to the measured height', () => {
767827
const onMentionsChange = jest.fn()
768-
const getComputedStyleSpy = jest.spyOn(window, 'getComputedStyle').mockReturnValue({
828+
const getComputedStyleSpy = jest.spyOn(globalThis, 'getComputedStyle').mockReturnValue({
769829
borderTopWidth: '4px',
770830
borderBottomWidth: '6px',
771831
} as unknown as CSSStyleDeclaration)
@@ -824,7 +884,7 @@ describe('MentionsInput', () => {
824884
</MentionsInput>
825885
)
826886

827-
const computed = window.getComputedStyle(textarea)
887+
const computed = globalThis.getComputedStyle(textarea)
828888
const borderTop = Number.parseFloat(computed.borderTopWidth || '0')
829889
const borderBottom = Number.parseFloat(computed.borderBottomWidth || '0')
830890
expect(Number.parseFloat(textarea.style.height)).toBe(scrollHeight + borderTop + borderBottom)
@@ -2032,16 +2092,28 @@ describe('MentionsInput', () => {
20322092
const originalRemove = window.removeEventListener
20332093
const handlers: Partial<Record<string, EventListener>> = {}
20342094
const addListener = jest
2035-
.spyOn(window, 'addEventListener')
2036-
.mockImplementation((type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions) => {
2037-
handlers[type] = listener as EventListener
2038-
return originalAdd.call(window, type, listener, options)
2039-
})
2095+
.spyOn(globalThis, 'addEventListener')
2096+
.mockImplementation(
2097+
(
2098+
type: string,
2099+
listener: EventListenerOrEventListenerObject,
2100+
options?: boolean | AddEventListenerOptions
2101+
) => {
2102+
handlers[type] = listener as EventListener
2103+
return originalAdd.call(globalThis, type, listener, options)
2104+
}
2105+
)
20402106
const removeListener = jest
2041-
.spyOn(window, 'removeEventListener')
2042-
.mockImplementation((type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions) => {
2043-
return originalRemove.call(window, type, listener, options)
2044-
})
2107+
.spyOn(globalThis, 'removeEventListener')
2108+
.mockImplementation(
2109+
(
2110+
type: string,
2111+
listener: EventListenerOrEventListenerObject,
2112+
options?: boolean | EventListenerOptions
2113+
) => {
2114+
return originalRemove.call(globalThis, type, listener, options)
2115+
}
2116+
)
20452117

20462118
const bridgeElement = instance.renderMeasurementBridge() as React.ReactElement
20472119
const { unmount: unmountBridge } = render(bridgeElement)
@@ -2060,14 +2132,14 @@ describe('MentionsInput', () => {
20602132
expect(updatePosition.mock.calls.length).toBe(positionCalls + 1)
20612133

20622134
act(() => {
2063-
window.dispatchEvent(new Event('resize'))
2135+
globalThis.dispatchEvent(new Event('resize'))
20642136
})
20652137

20662138
expect(syncScroll.mock.calls.length).toBeGreaterThan(syncCalls + 1)
20672139
expect(updatePosition.mock.calls.length).toBeGreaterThan(positionCalls + 1)
20682140

20692141
act(() => {
2070-
window.dispatchEvent(new Event('orientationchange'))
2142+
globalThis.dispatchEvent(new Event('orientationchange'))
20712143
})
20722144

20732145
expect(syncScroll.mock.calls.length).toBeGreaterThan(syncCalls + 2)

src/MentionsInput.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,12 @@ import {
2727
flattenSuggestions,
2828
mapPlainTextIndex,
2929
omit,
30-
readConfigFromChildren,
3130
spliceString,
3231
cn,
3332
} from './utils'
3433
import { areMentionSelectionsEqual } from './utils/areMentionSelectionsEqual'
3534
import { makeTriggerRegex } from './utils/makeTriggerRegex'
35+
import readConfigFromChildren from './utils/readConfigFromChildren'
3636
import { useEffectEvent } from './utils/useEffectEvent'
3737
import type {
3838
CaretCoordinates,
@@ -2076,7 +2076,7 @@ const MeasurementBridge = ({
20762076

20772077
useLayoutEffect(() => {
20782078
updateAll()
2079-
}, [updateAll])
2079+
}, [])
20802080

20812081
useLayoutEffect(() => observe(container, updateAll), [container, observe, updateAll])
20822082
useLayoutEffect(() => observe(highlighter, updateAll), [highlighter, observe, updateAll])
@@ -2087,7 +2087,7 @@ const MeasurementBridge = ({
20872087
)
20882088

20892089
useLayoutEffect(() => {
2090-
if (typeof window === 'undefined') {
2090+
if (globalThis.window === undefined) {
20912091
return undefined
20922092
}
20932093

@@ -2096,11 +2096,11 @@ const MeasurementBridge = ({
20962096
}
20972097

20982098
window.addEventListener('resize', handleViewportChange)
2099-
window.addEventListener('orientationchange', handleViewportChange)
2099+
globalThis.addEventListener('orientationchange', handleViewportChange)
21002100

21012101
return () => {
21022102
window.removeEventListener('resize', handleViewportChange)
2103-
window.removeEventListener('orientationchange', handleViewportChange)
2103+
globalThis.removeEventListener('orientationchange', handleViewportChange)
21042104
}
21052105
}, [updateAll])
21062106

src/utils/escapeRegex.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
// escape RegExp special characters https://stackoverflow.com/a/9310752/5142490
22
// eslint-disable-next-line code-complete/enforce-meaningful-names
3-
const escapeRegex = (str: string): string =>
4-
// eslint-disable-next-line unicorn/prefer-string-raw
5-
str.replaceAll(/[\s#$()*+,.?[\\\]^{|}-]/g, '\\$&')
3+
const escapeRegex = (str: string): string => str.replaceAll(/[\s#$()*+,.?[\\\]^{|}-]/g, '\\$&')
64

75
export default escapeRegex

src/utils/flattenSuggestions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable code-complete/enforce-meaningful-names */
12
import { Children } from 'react'
23
import type { ReactNode } from 'react'
34
import type { QueryInfo, SuggestionDataItem, SuggestionsMap } from '../types'
@@ -9,6 +10,7 @@ export interface FlattenedSuggestion<
910
queryInfo: QueryInfo
1011
}
1112

13+
// eslint-disable-next-line code-complete/low-function-cohesion
1214
const flattenSuggestions = <Extra extends Record<string, unknown> = Record<string, unknown>>(
1315
children: ReactNode,
1416
suggestions: SuggestionsMap<Extra> | undefined

src/utils/getMentionsAndPlainText.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const getMentionsAndPlainText = <Extra extends Record<string, unknown> = Record<
2121
plainTextIndex,
2222
})
2323
plainText += display
24-
idValue += String(id)
24+
idValue += id
2525
},
2626
(text) => {
2727
plainText += text

src/utils/getSubstringIndex.spec.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,10 @@ describe('#getSubstringIndex', () => {
9696

9797
it('skips characters gracefully when String#codePointAt reports undefined', () => {
9898
const haystackWrapper = new String('abc')
99-
const codePointAtMock = jest.fn(function (this: String, pos: number) {
100-
if (pos === 1) return undefined
99+
const codePointAtMock = jest.fn(function (this: string, pos: number) {
100+
if (pos === 1) {
101+
return undefined
102+
}
101103
return String.prototype.codePointAt.call(this, pos)
102104
})
103105
;(

src/utils/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,4 @@ export { default as isNumber } from './isNumber'
1313
export { default as iterateMentionsMarkup } from './iterateMentionsMarkup'
1414
export { default as mapPlainTextIndex } from './mapPlainTextIndex'
1515
export { default as omit } from './omit'
16-
export { default as readConfigFromChildren } from './readConfigFromChildren'
1716
export { default as spliceString } from './spliceString'

src/utils/isMentionElement.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { isValidElement, type ReactElement } from 'react'
2+
import type { MentionComponentProps } from '../types'
3+
import Mention from '../Mention'
4+
5+
export const isMentionElement = <Extra extends Record<string, unknown>>(
6+
child: unknown
7+
): child is ReactElement<MentionComponentProps<Extra>> =>
8+
isValidElement(child) &&
9+
child.type === Mention &&
10+
typeof child.props === 'object' &&
11+
child.props !== null &&
12+
'data' in child.props

0 commit comments

Comments
 (0)