diff --git a/.all-contributorsrc b/.all-contributorsrc
index a78eda08..2266503c 100644
--- a/.all-contributorsrc
+++ b/.all-contributorsrc
@@ -236,7 +236,8 @@
"profile": "https://twitter.com/diegohaz",
"contributions": [
"bug",
- "code"
+ "code",
+ "ideas"
]
},
{
@@ -325,7 +326,8 @@
"avatar_url": "https://avatars3.githubusercontent.com/u/10261750?v=4",
"profile": "https://github.com/pwolaq",
"contributions": [
- "test"
+ "test",
+ "code"
]
},
{
@@ -794,6 +796,35 @@
"code",
"test"
]
+ },
+ {
+ "login": "idanen",
+ "name": "Idan Entin",
+ "avatar_url": "https://avatars.githubusercontent.com/u/1687893?v=4",
+ "profile": "https://github.com/idanen",
+ "contributions": [
+ "code",
+ "test"
+ ]
+ },
+ {
+ "login": "mibcadet",
+ "name": "mibcadet",
+ "avatar_url": "https://avatars.githubusercontent.com/u/925500?v=4",
+ "profile": "https://github.com/mibcadet",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "silviuaavram",
+ "name": "Silviu Alexandru Avram",
+ "avatar_url": "https://avatars.githubusercontent.com/u/11275392?v=4",
+ "profile": "https://silviuaavram.com",
+ "contributions": [
+ "code",
+ "test"
+ ]
}
],
"repoHost": "https://github.com",
diff --git a/README.md b/README.md
index faac72dc..d34a2d91 100644
--- a/README.md
+++ b/README.md
@@ -81,6 +81,7 @@ clear to read and to maintain.
- [`toBePartiallyChecked`](#tobepartiallychecked)
- [`toHaveRole`](#tohaverole)
- [`toHaveErrorMessage`](#tohaveerrormessage)
+ - [`toHaveSelection`](#tohaveselection)
- [Deprecated matchers](#deprecated-matchers)
- [`toBeEmpty`](#tobeempty)
- [`toBeInTheDOM`](#tobeinthedom)
@@ -162,6 +163,21 @@ import '@testing-library/jest-dom/vitest'
setupFiles: ['./vitest-setup.js']
```
+Also, depending on your local setup, you may need to update your
+`tsconfig.json`:
+
+```json
+ // In tsconfig.json
+ "compilerOptions": {
+ ...
+ "types": ["vitest/globals", "@testing-library/jest-dom"]
+ },
+ "include": [
+ ...
+ "./vitest.setup.ts"
+ ],
+```
+
[vitest]: https://vitest.dev/
[vitest setupfiles]: https://vitest.dev/config/#setupfiles
@@ -1406,6 +1422,71 @@ expect(deleteButton).not.toHaveDescription()
expect(deleteButton).toHaveDescription('') // Missing or empty description always becomes a blank string
```
+
+
+### `toHaveSelection`
+
+This allows to assert that an element has a
+[text selection](https://developer.mozilla.org/en-US/docs/Web/API/Selection).
+
+This is useful to check if text or part of the text is selected within an
+element. The element can be either an input of type text, a textarea, or any
+other element that contains text, such as a paragraph, span, div etc.
+
+NOTE: the expected selection is a string, it does not allow to check for
+selection range indeces.
+
+```typescript
+toHaveSelection(expectedSelection?: string)
+```
+
+```html
+
+
+
+
prev
+
+ text selected text
+
+
next
+
+```
+
+```javascript
+getByTestId('text').setSelectionRange(5, 13)
+expect(getByTestId('text')).toHaveSelection('selected')
+
+getByTestId('textarea').setSelectionRange(0, 5)
+expect('textarea').toHaveSelection('text ')
+
+const selection = document.getSelection()
+const range = document.createRange()
+selection.removeAllRanges()
+selection.empty()
+selection.addRange(range)
+
+// selection of child applies to the parent as well
+range.selectNodeContents(getByTestId('child'))
+expect(getByTestId('child')).toHaveSelection('selected')
+expect(getByTestId('parent')).toHaveSelection('selected')
+
+// selection that applies from prev all, parent text before child, and part child.
+range.setStart(getByTestId('prev'), 0)
+range.setEnd(getByTestId('child').childNodes[0], 3)
+expect(queryByTestId('prev')).toHaveSelection('prev')
+expect(queryByTestId('child')).toHaveSelection('sel')
+expect(queryByTestId('parent')).toHaveSelection('text sel')
+expect(queryByTestId('next')).not.toHaveSelection()
+
+// selection that applies from part child, parent text after child and part next.
+range.setStart(getByTestId('child').childNodes[0], 3)
+range.setEnd(getByTestId('next').childNodes[0], 2)
+expect(queryByTestId('child')).toHaveSelection('ected')
+expect(queryByTestId('parent')).toHaveSelection('ected text')
+expect(queryByTestId('prev')).not.toHaveSelection()
+expect(queryByTestId('next')).toHaveSelection('ne')
+```
+
## Inspiration
This whole library was extracted out of Kent C. Dodds' [DOM Testing
@@ -1480,7 +1561,7 @@ Thanks goes to these people ([emoji key][emojis]):
Joe Hsu 📖
- Haz 🐛 💻
+ Haz 🐛 💻 🤔
Revath S Kumar 💻
hiwelo. 💻 🤔 ⚠️
Łukasz Fiszer 💻
@@ -1491,7 +1572,7 @@ Thanks goes to these people ([emoji key][emojis]):
Yarden Shoham 📖
Jaga Santagostino 🐛 ⚠️ 📖
Connor Meredith 💻 ⚠️ 📖
- Pawel Wolak ⚠️
+ Pawel Wolak ⚠️ 💻
Michaël De Boey 🚇
Jānis Zaržeckis 📖
koala-lava 📖
@@ -1554,6 +1635,9 @@ Thanks goes to these people ([emoji key][emojis]):
Aleksandr Elkin 🚧
Mordechai Dror 💻
Wayne Van Son 💻 ⚠️
+ Idan Entin 💻 ⚠️
+ mibcadet 📖
+ Silviu Alexandru Avram 💻 ⚠️
diff --git a/src/__tests__/to-have-selection.js b/src/__tests__/to-have-selection.js
new file mode 100644
index 00000000..9ddcc2c8
--- /dev/null
+++ b/src/__tests__/to-have-selection.js
@@ -0,0 +1,189 @@
+import {render} from './helpers/test-utils'
+
+describe('.toHaveSelection', () => {
+ test.each(['text', 'password', 'textarea'])(
+ 'handles selection within form elements',
+ testId => {
+ const {queryByTestId} = render(`
+
+
+
+ `)
+
+ queryByTestId(testId).setSelectionRange(5, 13)
+ expect(queryByTestId(testId)).toHaveSelection('selected')
+
+ queryByTestId(testId).select()
+ expect(queryByTestId(testId)).toHaveSelection('text selected text')
+ },
+ )
+
+ test.each(['checkbox', 'radio'])(
+ 'returns empty string for form elements without text',
+ testId => {
+ const {queryByTestId} = render(`
+
+
+ `)
+
+ queryByTestId(testId).select()
+ expect(queryByTestId(testId)).toHaveSelection('')
+ },
+ )
+
+ test('does not match subset string', () => {
+ const {queryByTestId} = render(`
+
+ `)
+
+ queryByTestId('text').setSelectionRange(5, 13)
+ expect(queryByTestId('text')).not.toHaveSelection('select')
+ expect(queryByTestId('text')).toHaveSelection('selected')
+ })
+
+ test('accepts any selection when expected selection is missing', () => {
+ const {queryByTestId} = render(`
+
+ `)
+
+ expect(queryByTestId('text')).not.toHaveSelection()
+
+ queryByTestId('text').setSelectionRange(5, 13)
+
+ expect(queryByTestId('text')).toHaveSelection()
+ })
+
+ test('throws when form element is not selected', () => {
+ const {queryByTestId} = render(`
+
+ `)
+
+ expect(() =>
+ expect(queryByTestId('text')).toHaveSelection(),
+ ).toThrowErrorMatchingInlineSnapshot(
+ `
+ expect(>element>).toHaveSelection(>expected>)>
+
+ Expected the element to have selection:
+ (any)>
+ Received:
+
+ `,
+ )
+ })
+
+ test('throws when form element is selected', () => {
+ const {queryByTestId} = render(`
+
+ `)
+ queryByTestId('text').setSelectionRange(5, 13)
+
+ expect(() =>
+ expect(queryByTestId('text')).not.toHaveSelection(),
+ ).toThrowErrorMatchingInlineSnapshot(
+ `
+ expect(>element>).not.toHaveSelection(>expected>)>
+
+ Expected the element not to have selection:
+ (any)>
+ Received:
+ selected>
+ `,
+ )
+ })
+
+ test('throws when element is not selected', () => {
+ const {queryByTestId} = render(`
+ text
+ `)
+
+ expect(() =>
+ expect(queryByTestId('text')).toHaveSelection(),
+ ).toThrowErrorMatchingInlineSnapshot(
+ `
+ expect(>element>).toHaveSelection(>expected>)>
+
+ Expected the element to have selection:
+ (any)>
+ Received:
+
+ `,
+ )
+ })
+
+ test('throws when element selection does not match', () => {
+ const {queryByTestId} = render(`
+
+ `)
+ queryByTestId('text').setSelectionRange(0, 4)
+
+ expect(() =>
+ expect(queryByTestId('text')).toHaveSelection('no match'),
+ ).toThrowErrorMatchingInlineSnapshot(
+ `
+ expect(>element>).toHaveSelection(>no match>)>
+
+ Expected the element to have selection:
+ no match>
+ Received:
+ text>
+ `,
+ )
+ })
+
+ test('handles selection within text nodes', () => {
+ const {queryByTestId} = render(`
+
+
prev
+
text selected text
+
next
+
+ `)
+
+ const selection = queryByTestId('child').ownerDocument.getSelection()
+ const range = queryByTestId('child').ownerDocument.createRange()
+ selection.removeAllRanges()
+ selection.empty()
+ selection.addRange(range)
+
+ range.selectNodeContents(queryByTestId('child'))
+
+ expect(queryByTestId('child')).toHaveSelection('selected')
+ expect(queryByTestId('parent')).toHaveSelection('selected')
+
+ range.selectNodeContents(queryByTestId('parent'))
+
+ expect(queryByTestId('child')).toHaveSelection('selected')
+ expect(queryByTestId('parent')).toHaveSelection('text selected text')
+
+ range.setStart(queryByTestId('prev'), 0)
+ range.setEnd(queryByTestId('child').childNodes[0], 3)
+
+ expect(queryByTestId('prev')).toHaveSelection('prev')
+ expect(queryByTestId('child')).toHaveSelection('sel')
+ expect(queryByTestId('parent')).toHaveSelection('text sel')
+ expect(queryByTestId('next')).not.toHaveSelection()
+
+ range.setStart(queryByTestId('child').childNodes[0], 3)
+ range.setEnd(queryByTestId('next').childNodes[0], 2)
+
+ expect(queryByTestId('child')).toHaveSelection('ected')
+ expect(queryByTestId('parent')).toHaveSelection('ected text')
+ expect(queryByTestId('prev')).not.toHaveSelection()
+ expect(queryByTestId('next')).toHaveSelection('ne')
+ })
+
+ test('throws with information when the expected selection is not string', () => {
+ const {container} = render(`1
`)
+ const element = container.firstChild
+ const range = element.ownerDocument.createRange()
+ range.selectNodeContents(element)
+ element.ownerDocument.getSelection().addRange(range)
+
+ expect(() =>
+ expect(element).toHaveSelection(1),
+ ).toThrowErrorMatchingInlineSnapshot(
+ `expected selection must be a string or undefined`,
+ )
+ })
+})
diff --git a/src/matchers.js b/src/matchers.js
index 46803f30..ed534e28 100644
--- a/src/matchers.js
+++ b/src/matchers.js
@@ -24,3 +24,4 @@ export {toBeChecked} from './to-be-checked'
export {toBePartiallyChecked} from './to-be-partially-checked'
export {toHaveDescription} from './to-have-description'
export {toHaveErrorMessage} from './to-have-errormessage'
+export {toHaveSelection} from './to-have-selection'
diff --git a/src/to-have-selection.js b/src/to-have-selection.js
new file mode 100644
index 00000000..55ce430a
--- /dev/null
+++ b/src/to-have-selection.js
@@ -0,0 +1,114 @@
+import isEqualWith from 'lodash/isEqualWith'
+import {checkHtmlElement, compareArraysAsSet, getMessage} from './utils'
+
+/**
+ * Returns the selection from the element.
+ *
+ * @param element {HTMLElement} The element to get the selection from.
+ * @returns {String} The selection.
+ */
+function getSelection(element) {
+ const selection = element.ownerDocument.getSelection()
+
+ if (['input', 'textarea'].includes(element.tagName.toLowerCase())) {
+ if (['radio', 'checkbox'].includes(element.type)) return ''
+ return element.value
+ .toString()
+ .substring(element.selectionStart, element.selectionEnd)
+ }
+
+ if (selection.anchorNode === null || selection.focusNode === null) {
+ // No selection
+ return ''
+ }
+
+ const originalRange = selection.getRangeAt(0)
+ const temporaryRange = element.ownerDocument.createRange()
+
+ if (selection.containsNode(element, false)) {
+ // Whole element is inside selection
+ temporaryRange.selectNodeContents(element)
+ selection.removeAllRanges()
+ selection.addRange(temporaryRange)
+ } else if (
+ element.contains(selection.anchorNode) &&
+ element.contains(selection.focusNode)
+ ) {
+ // Element contains selection, nothing to do
+ } else {
+ // Element is partially selected
+ const selectionStartsWithinElement =
+ element === originalRange.startContainer ||
+ element.contains(originalRange.startContainer)
+ const selectionEndsWithinElement =
+ element === originalRange.endContainer ||
+ element.contains(originalRange.endContainer)
+ selection.removeAllRanges()
+
+ if (selectionStartsWithinElement || selectionEndsWithinElement) {
+ temporaryRange.selectNodeContents(element)
+
+ if (selectionStartsWithinElement) {
+ temporaryRange.setStart(
+ originalRange.startContainer,
+ originalRange.startOffset,
+ )
+ }
+ if (selectionEndsWithinElement) {
+ temporaryRange.setEnd(
+ originalRange.endContainer,
+ originalRange.endOffset,
+ )
+ }
+
+ selection.addRange(temporaryRange)
+ }
+ }
+
+ const result = selection.toString()
+
+ selection.removeAllRanges()
+ selection.addRange(originalRange)
+
+ return result
+}
+
+/**
+ * Checks if the element has the string selected.
+ *
+ * @param htmlElement {HTMLElement} The html element to check the selection for.
+ * @param expectedSelection {String} The selection as a string.
+ */
+export function toHaveSelection(htmlElement, expectedSelection) {
+ checkHtmlElement(htmlElement, toHaveSelection, this)
+
+ const expectsSelection = expectedSelection !== undefined
+
+ if (expectsSelection && typeof expectedSelection !== 'string') {
+ throw new Error(`expected selection must be a string or undefined`)
+ }
+
+ const receivedSelection = getSelection(htmlElement)
+
+ return {
+ pass: expectsSelection
+ ? isEqualWith(receivedSelection, expectedSelection, compareArraysAsSet)
+ : Boolean(receivedSelection),
+ message: () => {
+ const to = this.isNot ? 'not to' : 'to'
+ const matcher = this.utils.matcherHint(
+ `${this.isNot ? '.not' : ''}.toHaveSelection`,
+ 'element',
+ expectedSelection,
+ )
+ return getMessage(
+ this,
+ matcher,
+ `Expected the element ${to} have selection`,
+ expectsSelection ? expectedSelection : '(any)',
+ 'Received',
+ receivedSelection,
+ )
+ },
+ }
+}
diff --git a/types/matchers.d.ts b/types/matchers.d.ts
index dfea7f10..8bf2ad58 100755
--- a/types/matchers.d.ts
+++ b/types/matchers.d.ts
@@ -703,6 +703,64 @@ declare namespace matchers {
* [testing-library/jest-dom#tohaveerrormessage](https://github.com/testing-library/jest-dom#tohaveerrormessage)
*/
toHaveErrorMessage(text?: string | RegExp | E): R
+ /**
+ * @description
+ * This allows to assert that an element has a
+ * [text selection](https://developer.mozilla.org/en-US/docs/Web/API/Selection).
+ *
+ * This is useful to check if text or part of the text is selected within an
+ * element. The element can be either an input of type text, a textarea, or any
+ * other element that contains text, such as a paragraph, span, div etc.
+ *
+ * NOTE: the expected selection is a string, it does not allow to check for
+ * selection range indeces.
+ *
+ * @example
+ *
+ *
+ *
+ *
prev
+ *
text selected text
+ *
next
+ *
+ *
+ * getByTestId('text').setSelectionRange(5, 13)
+ * expect(getByTestId('text')).toHaveSelection('selected')
+ *
+ * getByTestId('textarea').setSelectionRange(0, 5)
+ * expect('textarea').toHaveSelection('text ')
+ *
+ * const selection = document.getSelection()
+ * const range = document.createRange()
+ * selection.removeAllRanges()
+ * selection.empty()
+ * selection.addRange(range)
+ *
+ * // selection of child applies to the parent as well
+ * range.selectNodeContents(getByTestId('child'))
+ * expect(getByTestId('child')).toHaveSelection('selected')
+ * expect(getByTestId('parent')).toHaveSelection('selected')
+ *
+ * // selection that applies from prev all, parent text before child, and part child.
+ * range.setStart(getByTestId('prev'), 0)
+ * range.setEnd(getByTestId('child').childNodes[0], 3)
+ * expect(queryByTestId('prev')).toHaveSelection('prev')
+ * expect(queryByTestId('child')).toHaveSelection('sel')
+ * expect(queryByTestId('parent')).toHaveSelection('text sel')
+ * expect(queryByTestId('next')).not.toHaveSelection()
+ *
+ * // selection that applies from part child, parent text after child and part next.
+ * range.setStart(getByTestId('child').childNodes[0], 3)
+ * range.setEnd(getByTestId('next').childNodes[0], 2)
+ * expect(queryByTestId('child')).toHaveSelection('ected')
+ * expect(queryByTestId('parent')).toHaveSelection('ected text')
+ * expect(queryByTestId('prev')).not.toHaveSelection()
+ * expect(queryByTestId('next')).toHaveSelection('ne')
+ *
+ * @see
+ * [testing-library/jest-dom#tohaveselection](https://github.com/testing-library/jest-dom#tohaveselection)
+ */
+ toHaveSelection(selection?: string): R
}
}