Skip to content

Commit 9b14804

Browse files
silviuaavrampwolaq
andauthored
feat: implement toHaveSelection (#637)
Co-authored-by: Pawel Wolak <[email protected]>
1 parent f5b0e94 commit 9b14804

File tree

5 files changed

+430
-1
lines changed

5 files changed

+430
-1
lines changed

README.md

+68-1
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ clear to read and to maintain.
8181
- [`toBePartiallyChecked`](#tobepartiallychecked)
8282
- [`toHaveRole`](#tohaverole)
8383
- [`toHaveErrorMessage`](#tohaveerrormessage)
84+
- [`toHaveSelection`](#tohaveselection)
8485
- [Deprecated matchers](#deprecated-matchers)
8586
- [`toBeEmpty`](#tobeempty)
8687
- [`toBeInTheDOM`](#tobeinthedom)
@@ -162,7 +163,8 @@ import '@testing-library/jest-dom/vitest'
162163
setupFiles: ['./vitest-setup.js']
163164
```
164165

165-
Also, depending on your local setup, you may need to update your `tsconfig.json`:
166+
Also, depending on your local setup, you may need to update your
167+
`tsconfig.json`:
166168

167169
```json
168170
// In tsconfig.json
@@ -1420,6 +1422,71 @@ expect(deleteButton).not.toHaveDescription()
14201422
expect(deleteButton).toHaveDescription('') // Missing or empty description always becomes a blank string
14211423
```
14221424

1425+
<hr />
1426+
1427+
### `toHaveSelection`
1428+
1429+
This allows to assert that an element has a
1430+
[text selection](https://developer.mozilla.org/en-US/docs/Web/API/Selection).
1431+
1432+
This is useful to check if text or part of the text is selected within an
1433+
element. The element can be either an input of type text, a textarea, or any
1434+
other element that contains text, such as a paragraph, span, div etc.
1435+
1436+
NOTE: the expected selection is a string, it does not allow to check for
1437+
selection range indeces.
1438+
1439+
```typescript
1440+
toHaveSelection(expectedSelection?: string)
1441+
```
1442+
1443+
```html
1444+
<div>
1445+
<input type="text" value="text selected text" data-testid="text" />
1446+
<textarea data-testid="textarea">text selected text</textarea>
1447+
<p data-testid="prev">prev</p>
1448+
<p data-testid="parent">
1449+
text <span data-testid="child">selected</span> text
1450+
</p>
1451+
<p data-testid="next">next</p>
1452+
</div>
1453+
```
1454+
1455+
```javascript
1456+
getByTestId('text').setSelectionRange(5, 13)
1457+
expect(getByTestId('text')).toHaveSelection('selected')
1458+
1459+
getByTestId('textarea').setSelectionRange(0, 5)
1460+
expect('textarea').toHaveSelection('text ')
1461+
1462+
const selection = document.getSelection()
1463+
const range = document.createRange()
1464+
selection.removeAllRanges()
1465+
selection.empty()
1466+
selection.addRange(range)
1467+
1468+
// selection of child applies to the parent as well
1469+
range.selectNodeContents(getByTestId('child'))
1470+
expect(getByTestId('child')).toHaveSelection('selected')
1471+
expect(getByTestId('parent')).toHaveSelection('selected')
1472+
1473+
// selection that applies from prev all, parent text before child, and part child.
1474+
range.setStart(getByTestId('prev'), 0)
1475+
range.setEnd(getByTestId('child').childNodes[0], 3)
1476+
expect(queryByTestId('prev')).toHaveSelection('prev')
1477+
expect(queryByTestId('child')).toHaveSelection('sel')
1478+
expect(queryByTestId('parent')).toHaveSelection('text sel')
1479+
expect(queryByTestId('next')).not.toHaveSelection()
1480+
1481+
// selection that applies from part child, parent text after child and part next.
1482+
range.setStart(getByTestId('child').childNodes[0], 3)
1483+
range.setEnd(getByTestId('next').childNodes[0], 2)
1484+
expect(queryByTestId('child')).toHaveSelection('ected')
1485+
expect(queryByTestId('parent')).toHaveSelection('ected text')
1486+
expect(queryByTestId('prev')).not.toHaveSelection()
1487+
expect(queryByTestId('next')).toHaveSelection('ne')
1488+
```
1489+
14231490
## Inspiration
14241491

14251492
This whole library was extracted out of Kent C. Dodds' [DOM Testing

src/__tests__/to-have-selection.js

+189
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import {render} from './helpers/test-utils'
2+
3+
describe('.toHaveSelection', () => {
4+
test.each(['text', 'password', 'textarea'])(
5+
'handles selection within form elements',
6+
testId => {
7+
const {queryByTestId} = render(`
8+
<input type="text" value="text selected text" data-testid="text" />
9+
<input type="password" value="text selected text" data-testid="password" />
10+
<textarea data-testid="textarea">text selected text</textarea>
11+
`)
12+
13+
queryByTestId(testId).setSelectionRange(5, 13)
14+
expect(queryByTestId(testId)).toHaveSelection('selected')
15+
16+
queryByTestId(testId).select()
17+
expect(queryByTestId(testId)).toHaveSelection('text selected text')
18+
},
19+
)
20+
21+
test.each(['checkbox', 'radio'])(
22+
'returns empty string for form elements without text',
23+
testId => {
24+
const {queryByTestId} = render(`
25+
<input type="checkbox" value="checkbox" data-testid="checkbox" />
26+
<input type="radio" value="radio" data-testid="radio" />
27+
`)
28+
29+
queryByTestId(testId).select()
30+
expect(queryByTestId(testId)).toHaveSelection('')
31+
},
32+
)
33+
34+
test('does not match subset string', () => {
35+
const {queryByTestId} = render(`
36+
<input type="text" value="text selected text" data-testid="text" />
37+
`)
38+
39+
queryByTestId('text').setSelectionRange(5, 13)
40+
expect(queryByTestId('text')).not.toHaveSelection('select')
41+
expect(queryByTestId('text')).toHaveSelection('selected')
42+
})
43+
44+
test('accepts any selection when expected selection is missing', () => {
45+
const {queryByTestId} = render(`
46+
<input type="text" value="text selected text" data-testid="text" />
47+
`)
48+
49+
expect(queryByTestId('text')).not.toHaveSelection()
50+
51+
queryByTestId('text').setSelectionRange(5, 13)
52+
53+
expect(queryByTestId('text')).toHaveSelection()
54+
})
55+
56+
test('throws when form element is not selected', () => {
57+
const {queryByTestId} = render(`
58+
<input type="text" value="text selected text" data-testid="text" />
59+
`)
60+
61+
expect(() =>
62+
expect(queryByTestId('text')).toHaveSelection(),
63+
).toThrowErrorMatchingInlineSnapshot(
64+
`
65+
<dim>expect(</><red>element</><dim>).toHaveSelection(</><green>expected</><dim>)</>
66+
67+
Expected the element to have selection:
68+
<green> (any)</>
69+
Received:
70+
71+
`,
72+
)
73+
})
74+
75+
test('throws when form element is selected', () => {
76+
const {queryByTestId} = render(`
77+
<input type="text" value="text selected text" data-testid="text" />
78+
`)
79+
queryByTestId('text').setSelectionRange(5, 13)
80+
81+
expect(() =>
82+
expect(queryByTestId('text')).not.toHaveSelection(),
83+
).toThrowErrorMatchingInlineSnapshot(
84+
`
85+
<dim>expect(</><red>element</><dim>).not.toHaveSelection(</><green>expected</><dim>)</>
86+
87+
Expected the element not to have selection:
88+
<green> (any)</>
89+
Received:
90+
<red> selected</>
91+
`,
92+
)
93+
})
94+
95+
test('throws when element is not selected', () => {
96+
const {queryByTestId} = render(`
97+
<div data-testid="text">text</div>
98+
`)
99+
100+
expect(() =>
101+
expect(queryByTestId('text')).toHaveSelection(),
102+
).toThrowErrorMatchingInlineSnapshot(
103+
`
104+
<dim>expect(</><red>element</><dim>).toHaveSelection(</><green>expected</><dim>)</>
105+
106+
Expected the element to have selection:
107+
<green> (any)</>
108+
Received:
109+
110+
`,
111+
)
112+
})
113+
114+
test('throws when element selection does not match', () => {
115+
const {queryByTestId} = render(`
116+
<input type="text" value="text selected text" data-testid="text" />
117+
`)
118+
queryByTestId('text').setSelectionRange(0, 4)
119+
120+
expect(() =>
121+
expect(queryByTestId('text')).toHaveSelection('no match'),
122+
).toThrowErrorMatchingInlineSnapshot(
123+
`
124+
<dim>expect(</><red>element</><dim>).toHaveSelection(</><green>no match</><dim>)</>
125+
126+
Expected the element to have selection:
127+
<green> no match</>
128+
Received:
129+
<red> text</>
130+
`,
131+
)
132+
})
133+
134+
test('handles selection within text nodes', () => {
135+
const {queryByTestId} = render(`
136+
<div>
137+
<div data-testid="prev">prev</div>
138+
<div data-testid="parent">text <span data-testid="child">selected</span> text</div>
139+
<div data-testid="next">next</div>
140+
</div>
141+
`)
142+
143+
const selection = queryByTestId('child').ownerDocument.getSelection()
144+
const range = queryByTestId('child').ownerDocument.createRange()
145+
selection.removeAllRanges()
146+
selection.empty()
147+
selection.addRange(range)
148+
149+
range.selectNodeContents(queryByTestId('child'))
150+
151+
expect(queryByTestId('child')).toHaveSelection('selected')
152+
expect(queryByTestId('parent')).toHaveSelection('selected')
153+
154+
range.selectNodeContents(queryByTestId('parent'))
155+
156+
expect(queryByTestId('child')).toHaveSelection('selected')
157+
expect(queryByTestId('parent')).toHaveSelection('text selected text')
158+
159+
range.setStart(queryByTestId('prev'), 0)
160+
range.setEnd(queryByTestId('child').childNodes[0], 3)
161+
162+
expect(queryByTestId('prev')).toHaveSelection('prev')
163+
expect(queryByTestId('child')).toHaveSelection('sel')
164+
expect(queryByTestId('parent')).toHaveSelection('text sel')
165+
expect(queryByTestId('next')).not.toHaveSelection()
166+
167+
range.setStart(queryByTestId('child').childNodes[0], 3)
168+
range.setEnd(queryByTestId('next').childNodes[0], 2)
169+
170+
expect(queryByTestId('child')).toHaveSelection('ected')
171+
expect(queryByTestId('parent')).toHaveSelection('ected text')
172+
expect(queryByTestId('prev')).not.toHaveSelection()
173+
expect(queryByTestId('next')).toHaveSelection('ne')
174+
})
175+
176+
test('throws with information when the expected selection is not string', () => {
177+
const {container} = render(`<div>1</div>`)
178+
const element = container.firstChild
179+
const range = element.ownerDocument.createRange()
180+
range.selectNodeContents(element)
181+
element.ownerDocument.getSelection().addRange(range)
182+
183+
expect(() =>
184+
expect(element).toHaveSelection(1),
185+
).toThrowErrorMatchingInlineSnapshot(
186+
`expected selection must be a string or undefined`,
187+
)
188+
})
189+
})

src/matchers.js

+1
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,4 @@ export {toBeChecked} from './to-be-checked'
2424
export {toBePartiallyChecked} from './to-be-partially-checked'
2525
export {toHaveDescription} from './to-have-description'
2626
export {toHaveErrorMessage} from './to-have-errormessage'
27+
export {toHaveSelection} from './to-have-selection'

src/to-have-selection.js

+114
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import isEqualWith from 'lodash/isEqualWith'
2+
import {checkHtmlElement, compareArraysAsSet, getMessage} from './utils'
3+
4+
/**
5+
* Returns the selection from the element.
6+
*
7+
* @param element {HTMLElement} The element to get the selection from.
8+
* @returns {String} The selection.
9+
*/
10+
function getSelection(element) {
11+
const selection = element.ownerDocument.getSelection()
12+
13+
if (['input', 'textarea'].includes(element.tagName.toLowerCase())) {
14+
if (['radio', 'checkbox'].includes(element.type)) return ''
15+
return element.value
16+
.toString()
17+
.substring(element.selectionStart, element.selectionEnd)
18+
}
19+
20+
if (selection.anchorNode === null || selection.focusNode === null) {
21+
// No selection
22+
return ''
23+
}
24+
25+
const originalRange = selection.getRangeAt(0)
26+
const temporaryRange = element.ownerDocument.createRange()
27+
28+
if (selection.containsNode(element, false)) {
29+
// Whole element is inside selection
30+
temporaryRange.selectNodeContents(element)
31+
selection.removeAllRanges()
32+
selection.addRange(temporaryRange)
33+
} else if (
34+
element.contains(selection.anchorNode) &&
35+
element.contains(selection.focusNode)
36+
) {
37+
// Element contains selection, nothing to do
38+
} else {
39+
// Element is partially selected
40+
const selectionStartsWithinElement =
41+
element === originalRange.startContainer ||
42+
element.contains(originalRange.startContainer)
43+
const selectionEndsWithinElement =
44+
element === originalRange.endContainer ||
45+
element.contains(originalRange.endContainer)
46+
selection.removeAllRanges()
47+
48+
if (selectionStartsWithinElement || selectionEndsWithinElement) {
49+
temporaryRange.selectNodeContents(element)
50+
51+
if (selectionStartsWithinElement) {
52+
temporaryRange.setStart(
53+
originalRange.startContainer,
54+
originalRange.startOffset,
55+
)
56+
}
57+
if (selectionEndsWithinElement) {
58+
temporaryRange.setEnd(
59+
originalRange.endContainer,
60+
originalRange.endOffset,
61+
)
62+
}
63+
64+
selection.addRange(temporaryRange)
65+
}
66+
}
67+
68+
const result = selection.toString()
69+
70+
selection.removeAllRanges()
71+
selection.addRange(originalRange)
72+
73+
return result
74+
}
75+
76+
/**
77+
* Checks if the element has the string selected.
78+
*
79+
* @param htmlElement {HTMLElement} The html element to check the selection for.
80+
* @param expectedSelection {String} The selection as a string.
81+
*/
82+
export function toHaveSelection(htmlElement, expectedSelection) {
83+
checkHtmlElement(htmlElement, toHaveSelection, this)
84+
85+
const expectsSelection = expectedSelection !== undefined
86+
87+
if (expectsSelection && typeof expectedSelection !== 'string') {
88+
throw new Error(`expected selection must be a string or undefined`)
89+
}
90+
91+
const receivedSelection = getSelection(htmlElement)
92+
93+
return {
94+
pass: expectsSelection
95+
? isEqualWith(receivedSelection, expectedSelection, compareArraysAsSet)
96+
: Boolean(receivedSelection),
97+
message: () => {
98+
const to = this.isNot ? 'not to' : 'to'
99+
const matcher = this.utils.matcherHint(
100+
`${this.isNot ? '.not' : ''}.toHaveSelection`,
101+
'element',
102+
expectedSelection,
103+
)
104+
return getMessage(
105+
this,
106+
matcher,
107+
`Expected the element ${to} have selection`,
108+
expectsSelection ? expectedSelection : '(any)',
109+
'Received',
110+
receivedSelection,
111+
)
112+
},
113+
}
114+
}

0 commit comments

Comments
 (0)