Skip to content

Allow passing custom regex objects as props in order to handle more complex format strings #2

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

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
dist
node_modules

package-lock.json
20 changes: 20 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,26 @@ The `format` prop accepts any pattern - it's entirely up to you. Here's some ins
| Sort code | \#\# - \#\# - \#\# | 12 - 34 - 56 |
| Number plate | \#\#\#\# \#\#\# | LM68 XKC |

## Using custom regex

This component uses two regular expressions to handle the onChange event:
- one for stripping non-pattern characters (`strip`). Default value: `/[^\dA-z]/g`. Meaning: remove all characters that aren't numbers or letters.
- one for checking whether a character is allowed for input (`allow`). Default value: `/[\dA-z]/`. Meaning: check whether a submitted character is a number or a letter.

In some cases, such as with a `format="+1 (###) ###-##-##"`, custom regex for characters to strip and characters to allow are required.
In this example, the following custom regex needs to be passed into the component:
- `strip={ /^(\+1)|[^\d]/g } `: strip +1 from the input value's beginning; also strip all non-number characters.
- `allow={ /\d/ }`: allow only numbers.

Example of an Input component with those properties:

```JSX
import Input from 'react-input-auto-format';

function Foo () {
return <Input format="+1 (###) ###-##-##" strip={ /^(\+1)|[^\d]/g } allow={ /\d/ } />;
}
```
## Getting the raw value

To get the unformatted value, use the `onValueChange` prop.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@
"@babel/preset-react": "^7.14.5",
"@rollup/plugin-babel": "^5.3.0",
"@testing-library/dom": "^8.7.2",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^12.1.2",
"@testing-library/user-event": "^13.2.1",
"babel-jest": "^27.2.5",
"jest": "^27.2.5",
"jest-dom": "^4.0.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"rollup": "^2.58.0"
Expand Down
76 changes: 56 additions & 20 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import React, { useEffect, useRef, useState } from 'react';

// CHANGE: Moved placeholder declaration from format() to global scope
// REASON: So that the <Input> component can access it too
const placeholder = '#';
// CHANGE: Added a variable holding the RegExp object that is used after deletion to check whether there are pattern characters at the end of the substring
// REASON: To minimize the amount of times that RegExp object has to be reinitialized
let regexForStrippableCharacterAtTheEnd;

function noop() {}

function format(value, pattern) {
if (!pattern) return value;

const placeholder = '#';

let endOfValue = 0;
let characterIndex = 0;
let newValue = value;
Expand All @@ -33,51 +38,82 @@ function format(value, pattern) {
.join('');
}

function stripPatternCharacters(value) {
return value.replace(/[^\dA-z]/g, '');
// CHANGE: allow passing custom regex into the function
function stripPatternCharacters(value, regex) {
return value.replace(regex, '');
}

function isUserCharacter(character) {
return /[\dA-z]/.test(character);
// CHANGE: allow passing custom regex
// CHANGE: renamed the function to describe its function accurately even when it's used to check for stripRegex
function matchesRegex(character, regex) {
return regex.test(character);
}

function Input({
onChange = noop,
onValueChange = noop,
format: pattern,
value: userValue = '',
strip: stripRegex = /[^\dA-z]/g,
allow: allowRegex = /[\dA-z]/,
...rest
}) {
const [value, setValue] = useState(format(userValue, pattern));
const inputRef = useRef();
const infoRef = useRef({});


function handleChange(event) {
const { target } = event;
const { value: inputValue, selectionStart: cursorPosition } = target;
const didDelete = inputValue.length < value.length;

infoRef.current.cursorPosition = cursorPosition;
// CHANGE: Now the handler double-checks whether the cursor's position is located before the first placeholder in the input string
// REASON: Otherwise characters will be inserted out of order with a pattern like "+7 (###) ###-##-##" and cursorPosition < 5
const firstPlaceholderPosition = pattern.search(placeholder);
let cursorWasBeforeFirstPlaceholder = false;
if (cursorPosition >= firstPlaceholderPosition) {
// If the cursor's position is after the first placeholder, pass its position to infoRef unchanged
infoRef.current.cursorPosition = cursorPosition;
} else {
// If the cursor's position is located before the first placeholder,
// move cursor to the location of the first placeholder
cursorWasBeforeFirstPlaceholder = true;
infoRef.current.cursorPosition = firstPlaceholderPosition + 1;
}
// After all of this is done, read actual cursor position from infoRef for further use
const updatedCursorPosition = infoRef.current.cursorPosition;

let rawValue = stripPatternCharacters(inputValue);
let rawValue = stripPatternCharacters(inputValue, stripRegex);

// Processing the value after deletion
if (didDelete) {
const patternCharacterDeleted = !isUserCharacter(
[...value][cursorPosition]
// CHANGE: now patternCharacterDeleted checks whether the deleted character is included in stripRegex, not whether it's excluded from allowRegex
// REASON: this procedure matches its meaning more closely and allows for greater customizability

// Check whether the user has deleted a pattern character (one that is included in the stripRegex)
const patternCharacterDeleted = matchesRegex(
[...value][updatedCursorPosition],
stripRegex
);

// If the user has deleted a pattern character, delete the character immediately preceding it as well
if (patternCharacterDeleted) {
const firstBit = inputValue.substr(0, cursorPosition);
const rawFirstBit = stripPatternCharacters(firstBit);
const firstBit = inputValue.substr(0, updatedCursorPosition);
const rawFirstBit = stripPatternCharacters(firstBit, stripRegex);

rawValue =
rawFirstBit.substr(0, rawFirstBit.length - 1) +
stripPatternCharacters(
inputValue.substr(cursorPosition, inputValue.length)
inputValue.substr(updatedCursorPosition, inputValue.length),
stripRegex
);

if (!regexForStrippableCharacterAtTheEnd) {
regexForStrippableCharacterAtTheEnd = new RegExp(`(${allowRegex.source}+)${stripRegex.source}+$`);
}
infoRef.current.cursorPosition =
firstBit.replace(/([\d\w]+)[^\dA-z]+$/, '$1').length - 1;
firstBit.replace(regexForStrippableCharacterAtTheEnd, '$1').length - 1;
}
}

Expand All @@ -87,22 +123,22 @@ function Input({

if (!didDelete) {
const formattedCharacters = [...formattedValue];
const nextCharacter = formattedCharacters[cursorPosition];
const nextCharacterIsPattern = !isUserCharacter(nextCharacter);
const nextCharacter = formattedCharacters[updatedCursorPosition];
const nextCharacterIsPattern = matchesRegex(nextCharacter, stripRegex);
const nextUserCharacterIndex = formattedValue
.substr(cursorPosition)
.search(/[\dA-z]/);
.substr(updatedCursorPosition)
.search(allowRegex);
const numbersAhead = nextUserCharacterIndex !== -1;

infoRef.current.endOfSection = nextCharacterIsPattern && !numbersAhead;

if (
nextCharacterIsPattern &&
!isUserCharacter(formattedCharacters[cursorPosition - 1]) &&
!matchesRegex(formattedCharacters[updatedCursorPosition - 1], allowRegex) &&
numbersAhead
)
infoRef.current.cursorPosition =
cursorPosition + nextUserCharacterIndex + 1;
updatedCursorPosition + nextUserCharacterIndex + 1;
}

onValueChange(rawValue);
Expand Down
27 changes: 27 additions & 0 deletions src/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,31 @@ describe('The Input component', () => {
expect(input.selectionStart).toBe(cursorPosition);
}
);

it('should use custom RegExp objects correctly', () => {
const input = getInput({
format: '+1 (###) ###-##-##',
strip: /^(\+1)|[^\d]/g,
allow: /\d/g,
});

userEvent.type(input, 'a950123a4!(-812'),
expect(input.value).toBe('+1 (950) 123-48-12');
});

it('should input numbers in the correct order with a pattern that includes allowed characters (i.e. numbers) before the placeholder', () => {
const input = getInput({
format: '+1 (###) ###-##-##',
strip: /^(\+1)|[^\d]/g,
allow: /\d/g,
});

userEvent.type(input, '4812');
// Can't set selection range to 0, 0 because of some weird bug where setting it to 0, 0 does nothing in jest
// Checked it manually in a browser, and it works even when the cursor is moved to the start of a selection
input.setSelectionRange(2, 2);
userEvent.type(input, '950123');

expect(input.value).toBe('+1 (950) 123-48-12');
});
});
Loading