diff --git a/README.md b/README.md
index 90e6171c..5cea8e5a 100644
--- a/README.md
+++ b/README.md
@@ -97,6 +97,7 @@ when a real user uses it.
- [Using other assertion libraries](#using-other-assertion-libraries)
- [`TextMatch`](#textmatch)
- [Precision](#precision)
+ - [Normalization](#normalization)
- [TextMatch Examples](#textmatch-examples)
- [`query` APIs](#query-apis)
- [`queryAll` and `getAll` APIs](#queryall-and-getall-apis)
@@ -207,8 +208,7 @@ getByLabelText(
options?: {
selector?: string = '*',
exact?: boolean = true,
- collapseWhitespace?: boolean = true,
- trim?: boolean = true,
+ normalizer?: NormalizerFn,
}): HTMLElement
```
@@ -259,8 +259,7 @@ getByPlaceholderText(
text: TextMatch,
options?: {
exact?: boolean = true,
- collapseWhitespace?: boolean = false,
- trim?: boolean = true,
+ normalizer?: NormalizerFn,
}): HTMLElement
```
@@ -284,9 +283,8 @@ getByText(
options?: {
selector?: string = '*',
exact?: boolean = true,
- collapseWhitespace?: boolean = true,
- trim?: boolean = true,
- ignore?: string|boolean = 'script, style'
+ ignore?: string|boolean = 'script, style',
+ normalizer?: NormalizerFn,
}): HTMLElement
```
@@ -316,8 +314,7 @@ getByAltText(
text: TextMatch,
options?: {
exact?: boolean = true,
- collapseWhitespace?: boolean = false,
- trim?: boolean = true,
+ normalizer?: NormalizerFn,
}): HTMLElement
```
@@ -341,8 +338,7 @@ getByTitle(
title: TextMatch,
options?: {
exact?: boolean = true,
- collapseWhitespace?: boolean = false,
- trim?: boolean = true,
+ normalizer?: NormalizerFn,
}): HTMLElement
```
@@ -368,8 +364,7 @@ getByDisplayValue(
value: TextMatch,
options?: {
exact?: boolean = true,
- collapseWhitespace?: boolean = false,
- trim?: boolean = true,
+ normalizer?: NormalizerFn,
}): HTMLElement
```
@@ -416,8 +411,7 @@ getByRole(
text: TextMatch,
options?: {
exact?: boolean = true,
- collapseWhitespace?: boolean = false,
- trim?: boolean = true,
+ normalizer?: NormalizerFn,
}): HTMLElement
```
@@ -437,9 +431,8 @@ getByTestId(
text: TextMatch,
options?: {
exact?: boolean = true,
- collapseWhitespace?: boolean = false,
- trim?: boolean = true,
- }): HTMLElement`
+ normalizer?: NormalizerFn,
+ }): HTMLElement
```
A shortcut to `` container.querySelector(`[data-testid="${yourId}"]`) `` (and it
@@ -801,9 +794,47 @@ affect the precision of string matching:
- `exact` has no effect on `regex` or `function` arguments.
- In most cases using a regex instead of a string gives you more control over
fuzzy matching and should be preferred over `{ exact: false }`.
-- `trim`: Defaults to `true`; trim leading and trailing whitespace.
+- `normalizer`: An optional function which overrides normalization behavior.
+ See [`Normalization`](#normalization).
+
+### Normalization
+
+Before running any matching logic against text in the DOM, `dom-testing-library`
+automatically normalizes that text. By default, normalization consists of
+trimming whitespace from the start and end of text, and collapsing multiple
+adjacent whitespace characters into a single space.
+
+If you want to prevent that normalization, or provide alternative
+normalization (e.g. to remove Unicode control characters), you can provide a
+`normalizer` function in the options object. This function will be given
+a string and is expected to return a normalized version of that string.
+
+Note: Specifying a value for `normalizer` _replaces_ the built-in normalization, but
+you can call `getDefaultNormalizer` to obtain a built-in normalizer, either
+to adjust that normalization or to call it from your own normalizer.
+
+`getDefaultNormalizer` takes an options object which allows the selection of behaviour:
+
+- `trim`: Defaults to `true`. Trims leading and trailing whitespace
- `collapseWhitespace`: Defaults to `true`. Collapses inner whitespace (newlines, tabs, repeated spaces) into a single space.
+#### Normalization Examples
+
+To perform a match against text without trimming:
+
+```javascript
+getByText(node, 'text', {normalizer: getDefaultNormalizer({trim: false})})
+```
+
+To override normalization to remove some Unicode characters whilst keeping some (but not all) of the built-in normalization behavior:
+
+```javascript
+getByText(node, 'text', {
+ normalizer: str =>
+ getDefaultNormalizer({trim: false})(str).replace(/[\u200E-\u200F]*/g, ''),
+})
+```
+
### TextMatch Examples
```javascript
diff --git a/src/__tests__/matches.js b/src/__tests__/matches.js
index 8296a54f..5e4d7243 100644
--- a/src/__tests__/matches.js
+++ b/src/__tests__/matches.js
@@ -1,25 +1,28 @@
-import {fuzzyMatches, matches} from '../'
+import {fuzzyMatches, matches} from '../matches'
// unit tests for text match utils
const node = null
+const normalizer = str => str
test('matchers accept strings', () => {
- expect(matches('ABC', node, 'ABC')).toBe(true)
- expect(fuzzyMatches('ABC', node, 'ABC')).toBe(true)
+ expect(matches('ABC', node, 'ABC', normalizer)).toBe(true)
+ expect(fuzzyMatches('ABC', node, 'ABC', normalizer)).toBe(true)
})
test('matchers accept regex', () => {
- expect(matches('ABC', node, /ABC/)).toBe(true)
- expect(fuzzyMatches('ABC', node, /ABC/)).toBe(true)
+ expect(matches('ABC', node, /ABC/, normalizer)).toBe(true)
+ expect(fuzzyMatches('ABC', node, /ABC/, normalizer)).toBe(true)
})
test('matchers accept functions', () => {
- expect(matches('ABC', node, text => text === 'ABC')).toBe(true)
- expect(fuzzyMatches('ABC', node, text => text === 'ABC')).toBe(true)
+ expect(matches('ABC', node, text => text === 'ABC', normalizer)).toBe(true)
+ expect(fuzzyMatches('ABC', node, text => text === 'ABC', normalizer)).toBe(
+ true,
+ )
})
test('matchers return false if text to match is not a string', () => {
- expect(matches(null, node, 'ABC')).toBe(false)
- expect(fuzzyMatches(null, node, 'ABC')).toBe(false)
+ expect(matches(null, node, 'ABC', normalizer)).toBe(false)
+ expect(fuzzyMatches(null, node, 'ABC', normalizer)).toBe(false)
})
diff --git a/src/__tests__/text-matchers.js b/src/__tests__/text-matchers.js
index 5cf7b359..7cbd6924 100644
--- a/src/__tests__/text-matchers.js
+++ b/src/__tests__/text-matchers.js
@@ -1,5 +1,7 @@
import 'jest-dom/extend-expect'
import cases from 'jest-in-case'
+
+import {getDefaultNormalizer} from '../'
import {render} from './helpers/test-utils'
cases(
@@ -68,7 +70,12 @@ cases(
const queries = render(dom)
expect(queries[queryFn](query)).toHaveLength(1)
expect(
- queries[queryFn](query, {collapseWhitespace: false, trim: false}),
+ queries[queryFn](query, {
+ normalizer: getDefaultNormalizer({
+ collapseWhitespace: false,
+ trim: false,
+ }),
+ }),
).toHaveLength(0)
},
{
@@ -194,3 +201,128 @@ cases(
},
},
)
+
+// A good use case for a custom normalizer is stripping
+// out Unicode control characters such as LRM (left-right-mark)
+// before matching
+const LRM = '\u200e'
+function removeUCC(str) {
+ return str.replace(/[\u200e]/g, '')
+}
+
+cases(
+ '{ normalizer } option allows custom pre-match normalization',
+ ({dom, queryFn}) => {
+ const queries = render(dom)
+
+ const query = queries[queryFn]
+
+ // With the correct normalizer, we should match
+ expect(query(/user n.me/i, {normalizer: removeUCC})).toHaveLength(1)
+ expect(query('User name', {normalizer: removeUCC})).toHaveLength(1)
+
+ // Without the normalizer, we shouldn't
+ expect(query(/user n.me/i)).toHaveLength(0)
+ expect(query('User name')).toHaveLength(0)
+ },
+ {
+ queryAllByLabelText: {
+ dom: `
+
+ `,
+ queryFn: 'queryAllByLabelText',
+ },
+ queryAllByPlaceholderText: {
+ dom: ``,
+ queryFn: 'queryAllByPlaceholderText',
+ },
+ queryAllBySelectText: {
+ dom: ``,
+ queryFn: 'queryAllBySelectText',
+ },
+ queryAllByText: {
+ dom: `
User ${LRM}name
`,
+ queryFn: 'queryAllByText',
+ },
+ queryAllByAltText: {
+ dom: `
`,
+ queryFn: 'queryAllByAltText',
+ },
+ queryAllByTitle: {
+ dom: ``,
+ queryFn: 'queryAllByTitle',
+ },
+ queryAllByValue: {
+ dom: ``,
+ queryFn: 'queryAllByValue',
+ },
+ queryAllByDisplayValue: {
+ dom: ``,
+ queryFn: 'queryAllByDisplayValue',
+ },
+ queryAllByRole: {
+ dom: ``,
+ queryFn: 'queryAllByRole',
+ },
+ },
+)
+
+test('normalizer works with both exact and non-exact matching', () => {
+ const {queryAllByText} = render(`MiXeD ${LRM}CaSe
`)
+
+ expect(
+ queryAllByText('mixed case', {exact: false, normalizer: removeUCC}),
+ ).toHaveLength(1)
+ expect(
+ queryAllByText('mixed case', {exact: true, normalizer: removeUCC}),
+ ).toHaveLength(0)
+ expect(
+ queryAllByText('MiXeD CaSe', {exact: true, normalizer: removeUCC}),
+ ).toHaveLength(1)
+ expect(queryAllByText('MiXeD CaSe', {exact: true})).toHaveLength(0)
+})
+
+test('top-level trim and collapseWhitespace options are not supported if normalizer is specified', () => {
+ const {queryAllByText} = render(' abc def
')
+ const normalizer = str => str
+
+ expect(() => queryAllByText('abc', {trim: false, normalizer})).toThrow()
+ expect(() => queryAllByText('abc', {trim: true, normalizer})).toThrow()
+ expect(() =>
+ queryAllByText('abc', {collapseWhitespace: false, normalizer}),
+ ).toThrow()
+ expect(() =>
+ queryAllByText('abc', {collapseWhitespace: true, normalizer}),
+ ).toThrow()
+})
+
+test('getDefaultNormalizer returns a normalizer that supports trim and collapseWhitespace', () => {
+ // Default is trim: true and collapseWhitespace: true
+ expect(getDefaultNormalizer()(' abc def ')).toEqual('abc def')
+
+ // Turning off trimming should not turn off whitespace collapsing
+ expect(getDefaultNormalizer({trim: false})(' abc def ')).toEqual(
+ ' abc def ',
+ )
+
+ // Turning off whitespace collapsing should not turn off trimming
+ expect(
+ getDefaultNormalizer({collapseWhitespace: false})(' abc def '),
+ ).toEqual('abc def')
+
+ // Whilst it's rather pointless, we should be able to turn both off
+ expect(
+ getDefaultNormalizer({trim: false, collapseWhitespace: false})(
+ ' abc def ',
+ ),
+ ).toEqual(' abc def ')
+})
+
+test('we support an older API with trim and collapseWhitespace instead of a normalizer', () => {
+ const {queryAllByText} = render(' x y
')
+ expect(queryAllByText('x y')).toHaveLength(1)
+ expect(queryAllByText('x y', {trim: false})).toHaveLength(0)
+ expect(queryAllByText(' x y ', {trim: false})).toHaveLength(1)
+ expect(queryAllByText('x y', {collapseWhitespace: false})).toHaveLength(0)
+ expect(queryAllByText('x y', {collapseWhitespace: false})).toHaveLength(1)
+})
diff --git a/src/index.js b/src/index.js
index 5b9dbefa..c69df8a5 100644
--- a/src/index.js
+++ b/src/index.js
@@ -6,7 +6,7 @@ export * from './queries'
export * from './wait'
export * from './wait-for-element'
export * from './wait-for-dom-change'
-export * from './matches'
+export {getDefaultNormalizer} from './matches'
export * from './get-node-text'
export * from './events'
export * from './get-queries-for-element'
diff --git a/src/matches.js b/src/matches.js
index 1a79cb4f..3241e680 100644
--- a/src/matches.js
+++ b/src/matches.js
@@ -1,13 +1,9 @@
-function fuzzyMatches(
- textToMatch,
- node,
- matcher,
- {collapseWhitespace = true, trim = true} = {},
-) {
+function fuzzyMatches(textToMatch, node, matcher, normalizer) {
if (typeof textToMatch !== 'string') {
return false
}
- const normalizedText = normalize(textToMatch, {trim, collapseWhitespace})
+
+ const normalizedText = normalizer(textToMatch)
if (typeof matcher === 'string') {
return normalizedText.toLowerCase().includes(matcher.toLowerCase())
} else if (typeof matcher === 'function') {
@@ -17,16 +13,12 @@ function fuzzyMatches(
}
}
-function matches(
- textToMatch,
- node,
- matcher,
- {collapseWhitespace = true, trim = true} = {},
-) {
+function matches(textToMatch, node, matcher, normalizer) {
if (typeof textToMatch !== 'string') {
return false
}
- const normalizedText = normalize(textToMatch, {trim, collapseWhitespace})
+
+ const normalizedText = normalizer(textToMatch)
if (typeof matcher === 'string') {
return normalizedText === matcher
} else if (typeof matcher === 'function') {
@@ -36,13 +28,46 @@ function matches(
}
}
-function normalize(text, {trim, collapseWhitespace}) {
- let normalizedText = text
- normalizedText = trim ? normalizedText.trim() : normalizedText
- normalizedText = collapseWhitespace
- ? normalizedText.replace(/\s+/g, ' ')
- : normalizedText
- return normalizedText
+function getDefaultNormalizer({trim = true, collapseWhitespace = true} = {}) {
+ return text => {
+ let normalizedText = text
+ normalizedText = trim ? normalizedText.trim() : normalizedText
+ normalizedText = collapseWhitespace
+ ? normalizedText.replace(/\s+/g, ' ')
+ : normalizedText
+ return normalizedText
+ }
+}
+
+/**
+ * Constructs a normalizer to pass to functions in matches.js
+ * @param {boolean|undefined} trim The user-specified value for `trim`, without
+ * any defaulting having been applied
+ * @param {boolean|undefined} collapseWhitespace The user-specified value for
+ * `collapseWhitespace`, without any defaulting having been applied
+ * @param {Function|undefined} normalizer The user-specified normalizer
+ * @returns {Function} A normalizer
+ */
+function makeNormalizer({trim, collapseWhitespace, normalizer}) {
+ if (normalizer) {
+ // User has specified a custom normalizer
+ if (
+ typeof trim !== 'undefined' ||
+ typeof collapseWhitespace !== 'undefined'
+ ) {
+ // They've also specified a value for trim or collapseWhitespace
+ throw new Error(
+ 'trim and collapseWhitespace are not supported with a normalizer. ' +
+ 'If you want to use the default trim and collapseWhitespace logic in your normalizer, ' +
+ 'use "getDefaultNormalizer({trim, collapseWhitespace})" and compose that into your normalizer',
+ )
+ }
+
+ return normalizer
+ } else {
+ // No custom normalizer specified. Just use default.
+ return getDefaultNormalizer({trim, collapseWhitespace})
+ }
}
-export {fuzzyMatches, matches}
+export {fuzzyMatches, matches, getDefaultNormalizer, makeNormalizer}
diff --git a/src/queries.js b/src/queries.js
index d9bf7c7d..e5393222 100644
--- a/src/queries.js
+++ b/src/queries.js
@@ -1,4 +1,4 @@
-import {fuzzyMatches, matches} from './matches'
+import {fuzzyMatches, matches, makeNormalizer} from './matches'
import {getNodeText} from './get-node-text'
import {
getElementError,
@@ -15,22 +15,25 @@ import {getConfig} from './config'
function queryAllLabelsByText(
container,
text,
- {exact = true, trim = true, collapseWhitespace = true} = {},
+ {exact = true, trim, collapseWhitespace, normalizer} = {},
) {
const matcher = exact ? matches : fuzzyMatches
- const matchOpts = {collapseWhitespace, trim}
+ const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer})
return Array.from(container.querySelectorAll('label')).filter(label =>
- matcher(label.textContent, label, text, matchOpts),
+ matcher(label.textContent, label, text, matchNormalizer),
)
}
function queryAllByLabelText(
container,
text,
- {selector = '*', exact = true, collapseWhitespace = true, trim = true} = {},
+ {selector = '*', exact = true, collapseWhitespace, trim, normalizer} = {},
) {
- const matchOpts = {collapseWhitespace, trim}
- const labels = queryAllLabelsByText(container, text, {exact, ...matchOpts})
+ const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer})
+ const labels = queryAllLabelsByText(container, text, {
+ exact,
+ normalizer: matchNormalizer,
+ })
const labelledElements = labels
.map(label => {
if (label.control) {
@@ -62,7 +65,7 @@ function queryAllByLabelText(
const possibleAriaLabelElements = queryAllByText(container, text, {
exact,
- ...matchOpts,
+ normalizer: matchNormalizer,
}).filter(el => el.tagName !== 'LABEL') // don't reprocess labels
const ariaLabelledElements = possibleAriaLabelElements.reduce(
@@ -94,16 +97,17 @@ function queryAllByText(
{
selector = '*',
exact = true,
- collapseWhitespace = true,
- trim = true,
+ collapseWhitespace,
+ trim,
ignore = 'script, style',
+ normalizer,
} = {},
) {
const matcher = exact ? matches : fuzzyMatches
- const matchOpts = {collapseWhitespace, trim}
+ const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer})
return Array.from(container.querySelectorAll(selector))
.filter(node => !ignore || !node.matches(ignore))
- .filter(node => matcher(getNodeText(node), node, text, matchOpts))
+ .filter(node => matcher(getNodeText(node), node, text, matchNormalizer))
}
function queryByText(...args) {
@@ -113,14 +117,14 @@ function queryByText(...args) {
function queryAllByTitle(
container,
text,
- {exact = true, collapseWhitespace = true, trim = true} = {},
+ {exact = true, collapseWhitespace, trim, normalizer} = {},
) {
const matcher = exact ? matches : fuzzyMatches
- const matchOpts = {collapseWhitespace, trim}
+ const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer})
return Array.from(container.querySelectorAll('[title], svg > title')).filter(
node =>
- matcher(node.getAttribute('title'), node, text, matchOpts) ||
- matcher(getNodeText(node), node, text, matchOpts),
+ matcher(node.getAttribute('title'), node, text, matchNormalizer) ||
+ matcher(getNodeText(node), node, text, matchNormalizer),
)
}
@@ -131,16 +135,16 @@ function queryByTitle(...args) {
function queryAllBySelectText(
container,
text,
- {exact = true, collapseWhitespace = true, trim = true} = {},
+ {exact = true, collapseWhitespace, trim, normalizer} = {},
) {
const matcher = exact ? matches : fuzzyMatches
- const matchOpts = {collapseWhitespace, trim}
+ const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer})
return Array.from(container.querySelectorAll('select')).filter(selectNode => {
const selectedOptions = Array.from(selectNode.options).filter(
option => option.selected,
)
return selectedOptions.some(optionNode =>
- matcher(getNodeText(optionNode), optionNode, text, matchOpts),
+ matcher(getNodeText(optionNode), optionNode, text, matchNormalizer),
)
})
}
@@ -167,12 +171,12 @@ const queryAllByRole = queryAllByAttribute.bind(null, 'role')
function queryAllByAltText(
container,
alt,
- {exact = true, collapseWhitespace = true, trim = true} = {},
+ {exact = true, collapseWhitespace, trim, normalizer} = {},
) {
const matcher = exact ? matches : fuzzyMatches
- const matchOpts = {collapseWhitespace, trim}
+ const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer})
return Array.from(container.querySelectorAll('img,input,area')).filter(node =>
- matcher(node.getAttribute('alt'), node, alt, matchOpts),
+ matcher(node.getAttribute('alt'), node, alt, matchNormalizer),
)
}
@@ -183,10 +187,10 @@ function queryByAltText(...args) {
function queryAllByDisplayValue(
container,
value,
- {exact = true, collapseWhitespace = true, trim = true} = {},
+ {exact = true, collapseWhitespace, trim, normalizer} = {},
) {
const matcher = exact ? matches : fuzzyMatches
- const matchOpts = {collapseWhitespace, trim}
+ const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer})
return Array.from(container.querySelectorAll(`input,textarea,select`)).filter(
node => {
if (node.tagName === 'SELECT') {
@@ -194,10 +198,10 @@ function queryAllByDisplayValue(
option => option.selected,
)
return selectedOptions.some(optionNode =>
- matcher(getNodeText(optionNode), optionNode, value, matchOpts),
+ matcher(getNodeText(optionNode), optionNode, value, matchNormalizer),
)
} else {
- return matcher(node.value, node, value, matchOpts)
+ return matcher(node.value, node, value, matchNormalizer)
}
},
)
diff --git a/src/query-helpers.js b/src/query-helpers.js
index 1f701f38..8f849f47 100644
--- a/src/query-helpers.js
+++ b/src/query-helpers.js
@@ -1,5 +1,5 @@
import {prettyDOM} from './pretty-dom'
-import {fuzzyMatches, matches} from './matches'
+import {fuzzyMatches, matches, makeNormalizer} from './matches'
/* eslint-disable complexity */
function debugDOM(htmlElement) {
@@ -39,12 +39,12 @@ function queryAllByAttribute(
attribute,
container,
text,
- {exact = true, collapseWhitespace = true, trim = true} = {},
+ {exact = true, collapseWhitespace, trim, normalizer} = {},
) {
const matcher = exact ? matches : fuzzyMatches
- const matchOpts = {collapseWhitespace, trim}
+ const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer})
return Array.from(container.querySelectorAll(`[${attribute}]`)).filter(node =>
- matcher(node.getAttribute(attribute), node, text, matchOpts),
+ matcher(node.getAttribute(attribute), node, text, matchNormalizer),
)
}
diff --git a/typings/matches.d.ts b/typings/matches.d.ts
index af89af82..39e7b643 100644
--- a/typings/matches.d.ts
+++ b/typings/matches.d.ts
@@ -1,9 +1,15 @@
export type MatcherFunction = (content: string, element: HTMLElement) => boolean
export type Matcher = string | RegExp | MatcherFunction
+
+export type NormalizerFn = (text: string) => string
+
export interface MatcherOptions {
exact?: boolean
+ /** Use normalizer with getDefaultNormalizer instead */
trim?: boolean
+ /** Use normalizer with getDefaultNormalizer instead */
collapseWhitespace?: boolean
+ normalizer?: NormalizerFn
}
export type Match = (
@@ -13,5 +19,13 @@ export type Match = (
options?: MatcherOptions,
) => boolean
-export const fuzzyMatches: Match
-export const matches: Match
+export interface DefaultNormalizerOptions {
+ trim?: boolean
+ collapseWhitespace?: boolean
+}
+
+export declare function getDefaultNormalizer(
+ options?: DefaultNormalizerOptions,
+): NormalizerFn
+
+// N.B. Don't expose fuzzyMatches + matches here: they're not public API
diff --git a/typings/queries.d.ts b/typings/queries.d.ts
index 027169d4..42b45da7 100644
--- a/typings/queries.d.ts
+++ b/typings/queries.d.ts
@@ -1,7 +1,5 @@
import {Matcher, MatcherOptions} from './matches'
-import {
- SelectorMatcherOptions,
-} from './query-helpers'
+import {SelectorMatcherOptions} from './query-helpers'
export type QueryByBoundAttribute = (
container: HTMLElement,