Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement toHaveSelection #637

Merged
merged 3 commits into from
Oct 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading