Skip to content

Commit abd75dc

Browse files
feat(custom normalizer): allow custom control of normalization
Adds an optional {normalizer} option to query functions; this is a transformation function run over candidate match text after it has had `trim` or `collapseWhitespace` run on it, but before any matching text/function/regexp is tested against it. The use case is for tidying up DOM text (which may contain, for instance, invisible Unicode control characters) before running matching logic, keeping the matching logic and normalization logic separate.
1 parent 8ca67fe commit abd75dc

File tree

7 files changed

+170
-24
lines changed

7 files changed

+170
-24
lines changed

README.md

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ getByLabelText(
209209
exact?: boolean = true,
210210
collapseWhitespace?: boolean = true,
211211
trim?: boolean = true,
212+
normalizer?: NormalizerFn,
212213
}): HTMLElement
213214
```
214215

@@ -261,6 +262,7 @@ getByPlaceholderText(
261262
exact?: boolean = true,
262263
collapseWhitespace?: boolean = false,
263264
trim?: boolean = true,
265+
normalizer?: NormalizerFn,
264266
}): HTMLElement
265267
```
266268

@@ -285,6 +287,7 @@ getBySelectText(
285287
exact?: boolean = true,
286288
collapseWhitespace?: boolean = true,
287289
trim?: boolean = true,
290+
normalizer?: NormalizerFn,
288291
}): HTMLElement
289292
```
290293

@@ -315,7 +318,8 @@ getByText(
315318
exact?: boolean = true,
316319
collapseWhitespace?: boolean = true,
317320
trim?: boolean = true,
318-
ignore?: string|boolean = 'script, style'
321+
ignore?: string|boolean = 'script, style',
322+
normalizer?: NormalizerFn,
319323
}): HTMLElement
320324
```
321325

@@ -347,6 +351,7 @@ getByAltText(
347351
exact?: boolean = true,
348352
collapseWhitespace?: boolean = false,
349353
trim?: boolean = true,
354+
normalizer?: NormalizerFn,
350355
}): HTMLElement
351356
```
352357

@@ -372,6 +377,7 @@ getByTitle(
372377
exact?: boolean = true,
373378
collapseWhitespace?: boolean = false,
374379
trim?: boolean = true,
380+
normalizer?: NormalizerFn,
375381
}): HTMLElement
376382
```
377383

@@ -399,6 +405,7 @@ getByValue(
399405
exact?: boolean = true,
400406
collapseWhitespace?: boolean = false,
401407
trim?: boolean = true,
408+
normalizer?: NormalizerFn,
402409
}): HTMLElement
403410
```
404411

@@ -419,6 +426,7 @@ getByRole(
419426
exact?: boolean = true,
420427
collapseWhitespace?: boolean = false,
421428
trim?: boolean = true,
429+
normalizer?: NormalizerFn,
422430
}): HTMLElement
423431
```
424432

@@ -440,7 +448,8 @@ getByTestId(
440448
exact?: boolean = true,
441449
collapseWhitespace?: boolean = false,
442450
trim?: boolean = true,
443-
}): HTMLElement`
451+
normalizer?: NormalizerFn,
452+
}): HTMLElement
444453
```
445454

446455
A shortcut to `` container.querySelector(`[data-testid="${yourId}"]`) `` (and it
@@ -802,8 +811,15 @@ affect the precision of string matching:
802811
- `exact` has no effect on `regex` or `function` arguments.
803812
- In most cases using a regex instead of a string gives you more control over
804813
fuzzy matching and should be preferred over `{ exact: false }`.
805-
- `trim`: Defaults to `true`; trim leading and trailing whitespace.
814+
- `trim`: Defaults to `true`. Trims leading and trailing whitespace.
806815
- `collapseWhitespace`: Defaults to `true`. Collapses inner whitespace (newlines, tabs, repeated spaces) into a single space.
816+
- `normalizer`: Defaults to `undefined`. Specifies a custom function which will be called to normalize the text (after applying any `trim` or
817+
`collapseWhitespace` behaviour). An example use of this might be to remove Unicode control characters before applying matching behavior, e.g.
818+
```javascript
819+
{
820+
normalizer: str => str.replace(/[\u200E-\u200F]*/g, '')
821+
}
822+
```
807823

808824
### TextMatch Examples
809825

src/__tests__/text-matchers.js

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,3 +194,115 @@ cases(
194194
},
195195
},
196196
)
197+
198+
// A good use case for a custom normalizer is stripping
199+
// out UCC codes such as LRM before matching
200+
const LRM = '\u200e'
201+
function removeUCC(str) {
202+
return str.replace(/[\u200e]/g, '')
203+
}
204+
205+
cases(
206+
'{ normalizer } option allows custom pre-match normalization',
207+
({dom, queryFn}) => {
208+
const queries = render(dom)
209+
210+
const query = queries[queryFn]
211+
212+
// With the correct normalizer, we should match
213+
expect(query(/user n.me/i, {normalizer: removeUCC})).toHaveLength(1)
214+
expect(query('User name', {normalizer: removeUCC})).toHaveLength(1)
215+
216+
// Without the normalizer, we shouldn't
217+
expect(query(/user n.me/i)).toHaveLength(0)
218+
expect(query('User name')).toHaveLength(0)
219+
},
220+
{
221+
queryAllByLabelText: {
222+
dom: `
223+
<label for="username">User ${LRM}name</label>
224+
<input id="username" />`,
225+
queryFn: 'queryAllByLabelText',
226+
},
227+
queryAllByPlaceholderText: {
228+
dom: `<input placeholder="User ${LRM}name" />`,
229+
queryFn: 'queryAllByPlaceholderText',
230+
},
231+
queryAllBySelectText: {
232+
dom: `<select><option>User ${LRM}name</option></select>`,
233+
queryFn: 'queryAllBySelectText',
234+
},
235+
queryAllByText: {
236+
dom: `<div>User ${LRM}name</div>`,
237+
queryFn: 'queryAllByText',
238+
},
239+
queryAllByAltText: {
240+
dom: `<img alt="User ${LRM}name" src="username.jpg" />`,
241+
queryFn: 'queryAllByAltText',
242+
},
243+
queryAllByTitle: {
244+
dom: `<div title="User ${LRM}name" />`,
245+
queryFn: 'queryAllByTitle',
246+
},
247+
queryAllByValue: {
248+
dom: `<input value="User ${LRM}name" />`,
249+
queryFn: 'queryAllByValue',
250+
},
251+
queryAllByRole: {
252+
dom: `<input role="User ${LRM}name" />`,
253+
queryFn: 'queryAllByRole',
254+
},
255+
},
256+
)
257+
258+
test('normalizer works with both exact and non-exact matching', () => {
259+
const {queryAllByText} = render(`<div>MiXeD ${LRM}CaSe</div>`)
260+
261+
expect(
262+
queryAllByText('mixed case', {exact: false, normalizer: removeUCC}),
263+
).toHaveLength(1)
264+
expect(
265+
queryAllByText('mixed case', {exact: true, normalizer: removeUCC}),
266+
).toHaveLength(0)
267+
expect(
268+
queryAllByText('MiXeD CaSe', {exact: true, normalizer: removeUCC}),
269+
).toHaveLength(1)
270+
expect(queryAllByText('MiXeD CaSe', {exact: true})).toHaveLength(0)
271+
})
272+
273+
test('normalizer runs after trim and collapseWhitespace options', () => {
274+
// Our test text has leading and trailing spaces, and multiple
275+
// spaces in the middle; we'll make use of that fact to test
276+
// the execution order of trim/collapseWhitespace and the custom
277+
// normalizer
278+
const {queryAllByText} = render('<div> abc def </div>')
279+
280+
// Double-check the normal trim + collapseWhitespace behavior
281+
expect(
282+
queryAllByText('abc def', {trim: true, collapseWhitespace: true}),
283+
).toHaveLength(1)
284+
285+
// Test that again, but with a normalizer that replaces double
286+
// spaces with 'X' characters. If that runs before trim/collapseWhitespace,
287+
// it'll prevent successful matching
288+
expect(
289+
queryAllByText('abc def', {
290+
trim: true,
291+
collapseWhitespace: true,
292+
normalizer: str => str.replace(/ {2}/g, 'X'),
293+
}),
294+
).toHaveLength(1)
295+
296+
// Test that, if we turn off trim + collapse, that the normalizer does
297+
// then get to see the double whitespace, and we should now be able
298+
// to match the Xs
299+
expect(
300+
queryAllByText('XabcXdefX', {
301+
trim: false,
302+
collapseWhitespace: false,
303+
// With the whitespace left in, this will add Xs which will
304+
// prevent matching
305+
normalizer: str => str.replace(/ {2}/g, 'X'),
306+
}),
307+
).toHaveLength(1)
308+
})

src/matches.js

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,16 @@ function fuzzyMatches(
22
textToMatch,
33
node,
44
matcher,
5-
{collapseWhitespace = true, trim = true} = {},
5+
{collapseWhitespace = true, trim = true, normalizer} = {},
66
) {
77
if (typeof textToMatch !== 'string') {
88
return false
99
}
10-
const normalizedText = normalize(textToMatch, {trim, collapseWhitespace})
10+
const normalizedText = normalize(textToMatch, {
11+
trim,
12+
collapseWhitespace,
13+
normalizer,
14+
})
1115
if (typeof matcher === 'string') {
1216
return normalizedText.toLowerCase().includes(matcher.toLowerCase())
1317
} else if (typeof matcher === 'function') {
@@ -21,12 +25,16 @@ function matches(
2125
textToMatch,
2226
node,
2327
matcher,
24-
{collapseWhitespace = true, trim = true} = {},
28+
{collapseWhitespace = true, trim = true, normalizer} = {},
2529
) {
2630
if (typeof textToMatch !== 'string') {
2731
return false
2832
}
29-
const normalizedText = normalize(textToMatch, {trim, collapseWhitespace})
33+
const normalizedText = normalize(textToMatch, {
34+
trim,
35+
collapseWhitespace,
36+
normalizer,
37+
})
3038
if (typeof matcher === 'string') {
3139
return normalizedText === matcher
3240
} else if (typeof matcher === 'function') {
@@ -36,12 +44,13 @@ function matches(
3644
}
3745
}
3846

39-
function normalize(text, {trim, collapseWhitespace}) {
47+
function normalize(text, {trim, collapseWhitespace, normalizer}) {
4048
let normalizedText = text
4149
normalizedText = trim ? normalizedText.trim() : normalizedText
4250
normalizedText = collapseWhitespace
4351
? normalizedText.replace(/\s+/g, ' ')
4452
: normalizedText
53+
normalizedText = normalizer ? normalizer(normalizedText) : normalizedText
4554
return normalizedText
4655
}
4756

src/queries.js

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@ import {getConfig} from './config'
1515
function queryAllLabelsByText(
1616
container,
1717
text,
18-
{exact = true, trim = true, collapseWhitespace = true} = {},
18+
{exact = true, trim = true, collapseWhitespace = true, normalizer} = {},
1919
) {
2020
const matcher = exact ? matches : fuzzyMatches
21-
const matchOpts = {collapseWhitespace, trim}
21+
const matchOpts = {collapseWhitespace, trim, normalizer}
2222
return Array.from(container.querySelectorAll('label')).filter(label =>
2323
matcher(label.textContent, label, text, matchOpts),
2424
)
@@ -27,9 +27,15 @@ function queryAllLabelsByText(
2727
function queryAllByLabelText(
2828
container,
2929
text,
30-
{selector = '*', exact = true, collapseWhitespace = true, trim = true} = {},
30+
{
31+
selector = '*',
32+
exact = true,
33+
collapseWhitespace = true,
34+
trim = true,
35+
normalizer,
36+
} = {},
3137
) {
32-
const matchOpts = {collapseWhitespace, trim}
38+
const matchOpts = {collapseWhitespace, trim, normalizer}
3339
const labels = queryAllLabelsByText(container, text, {exact, ...matchOpts})
3440
const labelledElements = labels
3541
.map(label => {
@@ -97,10 +103,11 @@ function queryAllByText(
97103
collapseWhitespace = true,
98104
trim = true,
99105
ignore = 'script, style',
106+
normalizer,
100107
} = {},
101108
) {
102109
const matcher = exact ? matches : fuzzyMatches
103-
const matchOpts = {collapseWhitespace, trim}
110+
const matchOpts = {collapseWhitespace, trim, normalizer}
104111
return Array.from(container.querySelectorAll(selector))
105112
.filter(node => !ignore || !node.matches(ignore))
106113
.filter(node => matcher(getNodeText(node), node, text, matchOpts))
@@ -113,10 +120,10 @@ function queryByText(...args) {
113120
function queryAllByTitle(
114121
container,
115122
text,
116-
{exact = true, collapseWhitespace = true, trim = true} = {},
123+
{exact = true, collapseWhitespace = true, trim = true, normalizer} = {},
117124
) {
118125
const matcher = exact ? matches : fuzzyMatches
119-
const matchOpts = {collapseWhitespace, trim}
126+
const matchOpts = {collapseWhitespace, trim, normalizer}
120127
return Array.from(container.querySelectorAll('[title], svg > title')).filter(
121128
node =>
122129
matcher(node.getAttribute('title'), node, text, matchOpts) ||
@@ -131,10 +138,10 @@ function queryByTitle(...args) {
131138
function queryAllBySelectText(
132139
container,
133140
text,
134-
{exact = true, collapseWhitespace = true, trim = true} = {},
141+
{exact = true, collapseWhitespace = true, trim = true, normalizer} = {},
135142
) {
136143
const matcher = exact ? matches : fuzzyMatches
137-
const matchOpts = {collapseWhitespace, trim}
144+
const matchOpts = {collapseWhitespace, trim, normalizer}
138145
return Array.from(container.querySelectorAll('select')).filter(selectNode => {
139146
const selectedOptions = Array.from(selectNode.options).filter(
140147
option => option.selected,
@@ -167,10 +174,10 @@ const queryAllByRole = queryAllByAttribute.bind(null, 'role')
167174
function queryAllByAltText(
168175
container,
169176
alt,
170-
{exact = true, collapseWhitespace = true, trim = true} = {},
177+
{exact = true, collapseWhitespace = true, trim = true, normalizer} = {},
171178
) {
172179
const matcher = exact ? matches : fuzzyMatches
173-
const matchOpts = {collapseWhitespace, trim}
180+
const matchOpts = {collapseWhitespace, trim, normalizer}
174181
return Array.from(container.querySelectorAll('img,input,area')).filter(node =>
175182
matcher(node.getAttribute('alt'), node, alt, matchOpts),
176183
)

src/query-helpers.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,10 @@ function queryAllByAttribute(
3939
attribute,
4040
container,
4141
text,
42-
{exact = true, collapseWhitespace = true, trim = true} = {},
42+
{exact = true, collapseWhitespace = true, trim = true, normalizer} = {},
4343
) {
4444
const matcher = exact ? matches : fuzzyMatches
45-
const matchOpts = {collapseWhitespace, trim}
45+
const matchOpts = {collapseWhitespace, trim, normalizer}
4646
return Array.from(container.querySelectorAll(`[${attribute}]`)).filter(node =>
4747
matcher(node.getAttribute(attribute), node, text, matchOpts),
4848
)

typings/matches.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
export type MatcherFunction = (content: string, element: HTMLElement) => boolean
22
export type Matcher = string | RegExp | MatcherFunction
3+
4+
export type NormalizerFn = (text: string) => string
5+
36
export interface MatcherOptions {
47
exact?: boolean
58
trim?: boolean
69
collapseWhitespace?: boolean
10+
normalizer?: NormalizerFn
711
}
812

913
export type Match = (

typings/queries.d.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import {Matcher, MatcherOptions} from './matches'
2-
import {
3-
SelectorMatcherOptions,
4-
} from './query-helpers'
2+
import {SelectorMatcherOptions} from './query-helpers'
53

64
export type QueryByBoundAttribute = (
75
container: HTMLElement,

0 commit comments

Comments
 (0)