Skip to content

Commit

Permalink
feat: implement toHaveSelection (#637)
Browse files Browse the repository at this point in the history
Co-authored-by: Pawel Wolak <[email protected]>
  • Loading branch information
silviuaavram and pwolaq authored Oct 16, 2024
1 parent f5b0e94 commit 9b14804
Show file tree
Hide file tree
Showing 5 changed files with 430 additions and 1 deletion.
69 changes: 68 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -162,7 +163,8 @@ import '@testing-library/jest-dom/vitest'
setupFiles: ['./vitest-setup.js']
```

Also, depending on your local setup, you may need to update your `tsconfig.json`:
Also, depending on your local setup, you may need to update your
`tsconfig.json`:

```json
// In tsconfig.json
Expand Down Expand Up @@ -1420,6 +1422,71 @@ expect(deleteButton).not.toHaveDescription()
expect(deleteButton).toHaveDescription('') // Missing or empty description always becomes a blank string
```

<hr />

### `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
<div>
<input type="text" value="text selected text" data-testid="text" />
<textarea data-testid="textarea">text selected text</textarea>
<p data-testid="prev">prev</p>
<p data-testid="parent">
text <span data-testid="child">selected</span> text
</p>
<p data-testid="next">next</p>
</div>
```

```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
Expand Down
189 changes: 189 additions & 0 deletions src/__tests__/to-have-selection.js
Original file line number Diff line number Diff line change
@@ -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(`
<input type="text" value="text selected text" data-testid="text" />
<input type="password" value="text selected text" data-testid="password" />
<textarea data-testid="textarea">text selected text</textarea>
`)

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(`
<input type="checkbox" value="checkbox" data-testid="checkbox" />
<input type="radio" value="radio" data-testid="radio" />
`)

queryByTestId(testId).select()
expect(queryByTestId(testId)).toHaveSelection('')
},
)

test('does not match subset string', () => {
const {queryByTestId} = render(`
<input type="text" value="text selected text" data-testid="text" />
`)

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(`
<input type="text" value="text selected text" data-testid="text" />
`)

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(`
<input type="text" value="text selected text" data-testid="text" />
`)

expect(() =>
expect(queryByTestId('text')).toHaveSelection(),
).toThrowErrorMatchingInlineSnapshot(
`
<dim>expect(</><red>element</><dim>).toHaveSelection(</><green>expected</><dim>)</>
Expected the element to have selection:
<green> (any)</>
Received:
`,
)
})

test('throws when form element is selected', () => {
const {queryByTestId} = render(`
<input type="text" value="text selected text" data-testid="text" />
`)
queryByTestId('text').setSelectionRange(5, 13)

expect(() =>
expect(queryByTestId('text')).not.toHaveSelection(),
).toThrowErrorMatchingInlineSnapshot(
`
<dim>expect(</><red>element</><dim>).not.toHaveSelection(</><green>expected</><dim>)</>
Expected the element not to have selection:
<green> (any)</>
Received:
<red> selected</>
`,
)
})

test('throws when element is not selected', () => {
const {queryByTestId} = render(`
<div data-testid="text">text</div>
`)

expect(() =>
expect(queryByTestId('text')).toHaveSelection(),
).toThrowErrorMatchingInlineSnapshot(
`
<dim>expect(</><red>element</><dim>).toHaveSelection(</><green>expected</><dim>)</>
Expected the element to have selection:
<green> (any)</>
Received:
`,
)
})

test('throws when element selection does not match', () => {
const {queryByTestId} = render(`
<input type="text" value="text selected text" data-testid="text" />
`)
queryByTestId('text').setSelectionRange(0, 4)

expect(() =>
expect(queryByTestId('text')).toHaveSelection('no match'),
).toThrowErrorMatchingInlineSnapshot(
`
<dim>expect(</><red>element</><dim>).toHaveSelection(</><green>no match</><dim>)</>
Expected the element to have selection:
<green> no match</>
Received:
<red> text</>
`,
)
})

test('handles selection within text nodes', () => {
const {queryByTestId} = render(`
<div>
<div data-testid="prev">prev</div>
<div data-testid="parent">text <span data-testid="child">selected</span> text</div>
<div data-testid="next">next</div>
</div>
`)

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(`<div>1</div>`)
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`,
)
})
})
1 change: 1 addition & 0 deletions src/matchers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
114 changes: 114 additions & 0 deletions src/to-have-selection.js
Original file line number Diff line number Diff line change
@@ -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,
)
},
}
}
Loading

0 comments on commit 9b14804

Please sign in to comment.