13

I have a credit card field that I want to handle while the user inputs its credit card number. Assumptions are that the user can enter digits and alphabetic characters, and a space must be added every four characters. The input part works fine, but I have problems with backspace. Deleting with the backspace key works if I the cursor is on a digits, but it does not work fine when the cursor is on a space: in this case the user must hold backspace to properly delete some input.

An additional requirement is to let clipboard actions (copy, cut, paste) work properly on that field.

I cannot use any plugin for the solution (like the JQuery Mask Plugin), and I won't use keyCode directly, if possible.

Updated JS Fiddle: https://jsfiddle.net/ot2t9zr4/10/

Snippet

$('#credit-card').on('keypress change blur', function () {
  $(this).val(function (index, value) {
    return value.replace(/[^a-z0-9]+/gi, '').replace(/(.{4})/g, '$1 ');
  });
});

$('#credit-card').on('copy cut paste', function () {
  setTimeout(function () {
    $('#credit-card').trigger("change");
  });
});
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js"></script>
<div class="container">
  <form class="" action="" method="post">
    <fieldset>
      <legend>Payment</legend>
      <div class="beautiful-field field-group credit-cart">
        <label class="label" for="credit-card">Credit card</label>
        <input class="field" id="credit-card" value="" autocomplete="off" type="text" />
      </div>
    </fieldset>
  </form>
</div>
ramo102
  • 505
  • 3
  • 9
  • 23
  • Note that there are more formats than just chunking every 4 characters. See https://baymard.com/checkout-usability/credit-card-patterns. – GaryJ Apr 25 '18 at 09:14

10 Answers10

32

Bind keypress event only and see.

$('#credit-card').on('keypress change', function () {
  $(this).val(function (index, value) {
    return value.replace(/\W/gi, '').replace(/(.{4})/g, '$1 ');
  });
});

Check here.

Developer107
  • 1,728
  • 2
  • 14
  • 24
12

Steve Davies already pointed it out, but if you only reformat the whole value with replace(), the caret position will always go at the end of the input value which can be annoying if the user edits what he previously entered. It will lead to a bad user experience if the caret position is elsewhere or a selection has been made in order to replace it with a new digit.

That being said, a good way to get rid of that behavior is to create a custom replace function with a for loop that goes through each character, then you will be able to know if the space inserted is before the current caret position and update the position if it's the case.

Pure javascript solution: https://jsfiddle.net/pmrotule/217u7fru/.

EDIT: I added support for the American Express format (15 digits instead of 16).

input_credit_card = function(jQinp)
{
    var format_and_pos = function(input, char, backspace)
    {
        var start = 0;
        var end = 0;
        var pos = 0;
        var value = input.value;

        if (char !== false)
        {
            start = input.selectionStart;
            end = input.selectionEnd;

            if (backspace && start > 0) // handle backspace onkeydown
            {
                start--;

                if (value[start] == " ")
                { start--; }
            }
            // To be able to replace the selection if there is one
            value = value.substring(0, start) + char + value.substring(end);

            pos = start + char.length; // caret position
        }

        var d = 0; // digit count
        var dd = 0; // total
        var gi = 0; // group index
        var newV = "";
        var groups = /^\D*3[47]/.test(value) ? // check for American Express
        [4, 6, 5] : [4, 4, 4, 4];

        for (var i = 0; i < value.length; i++)
        {
            if (/\D/.test(value[i]))
            {
                if (start > i)
                { pos--; }
            }
            else
            {
                if (d === groups[gi])
                {
                    newV += " ";
                    d = 0;
                    gi++;

                    if (start >= i)
                    { pos++; }
                }
                newV += value[i];
                d++;
                dd++;
            }
            if (d === groups[gi] && groups.length === gi + 1) // max length
            { break; }
        }
        input.value = newV;

        if (char !== false)
        { input.setSelectionRange(pos, pos); }
    };

    jQinp.keypress(function(e)
    {
        var code = e.charCode || e.keyCode || e.which;

        // Check for tab and arrow keys (needed in Firefox)
        if (code !== 9 && (code < 37 || code > 40) &&
        // and CTRL+C / CTRL+V
        !(e.ctrlKey && (code === 99 || code === 118)))
        {
            e.preventDefault();

            var char = String.fromCharCode(code);

            // if the character is non-digit
            // -> return false (the character is not inserted)

            if (/\D/.test(char))
            { return false; }

            format_and_pos(this, char);
        }
    }).
    keydown(function(e) // backspace doesn't fire the keypress event
    {
        if (e.keyCode === 8 || e.keyCode === 46) // backspace or delete
        {
            e.preventDefault();
            format_and_pos(this, '', this.selectionStart === this.selectionEnd);
        }
    }).
    on('paste', function()
    {
        // A timeout is needed to get the new value pasted
        setTimeout(function()
        { format_and_pos(jQinp[0], ''); }, 50);
    }).
    blur(function() // reformat onblur just in case (optional)
    {
        format_and_pos(this, false);
    });
};

input_credit_card($('#credit-card'));
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js"></script>
<div class="container">
  <form class="" action="" method="post">
    <fieldset>
      <legend>Payment</legend>
      <div class="beautiful-field field-group credit-cart">
        <label class="label" for="credit-card">Credit card</label>
        <input class="field" id="credit-card" value="" autocomplete="off" type="text" />
      </div>
    </fieldset>
  </form>
</div>
pmrotule
  • 9,065
  • 4
  • 50
  • 58
  • I'm not able to move with the arrow keys, with your solution. – ramo102 Oct 06 '16 at 13:09
  • @ramo102 - This is weird because the arrow keys are not supposed to fire the keypress event and by cons, the normal behavior is not prevented by `e.preventDefault()`. I tested it with Chrome and IE11 and you can move with the arrow keys. What browser did you use? – pmrotule Oct 06 '16 at 18:19
  • @ramo102 - Thanks for pointing that out. I modified my code to support arrow keys in Firefox. I also added support for pasted content. – pmrotule Oct 20 '16 at 10:49
  • This code is very useful. The only niggles are that it doesn't handle the Home and End keys, and that the Del key works like the Backspace key. This is solved easily enough, though. – SeverityOne Mar 28 '18 at 08:11
  • FYI, for macOS you will need to change `e.ctrlKey` to `(e.ctrlKey || e.metaKey)` to check for the COMMAND key press. (copy, paste, etc) – stldoug Apr 20 '18 at 18:45
  • FYI, `char` is a reserved keyword in JavaScript and should not be used as function param/variable name – ESR May 09 '18 at 05:56
  • the input data disappeared after i select all numbers with mouse then leave the input – Ahmed Taha Jun 29 '21 at 19:59
  • Pretty good: 1 more tip; change the 'type' attribute of the input html to 'number' and add attributes: pattern="[0-9]*" inputmode="numeric". Not only will this let html validate your input for you; but on mobile this will default the keypad to a numeric keypad, which according to Stripe's extensive research into thousands of checkouts; will increase your conversion rate by a couple of percent. (adding spaces in between CC number groups will also increase your conversion rate according to the research ;) – Reece Dec 05 '22 at 18:11
  • Hmm.. actually, testing my recommendation in your JS fiddle kills your code completely.. Shame - cause I think numeric keypad on mobile is quite important.. Will play around & look for a way to get both! – Reece Dec 05 '22 at 18:26
5

Since I cannot just reply to Developer107's comment; If you only want digits (with regex and don't want to specify it on the field. You can do it like this:

$('#credit-card').on('keypress change', function () {
   $(this).val(function (index, value) {
       return value.replace(/[^0-9]/g, "").replace(/\W/gi, '').replace(/(.{4})/g, '$1 ');
   });
});

https://jsfiddle.net/ot2t9zr4/4/

MaggiQ
  • 51
  • 3
  • Digits only is not required. For the rest, your solution is the same as @Developer107's one. – ramo102 Apr 12 '16 at 10:44
  • 3
    Yes I am aware of that; that's why I said I cannot just reply to his comments because I don't have 50 points yet! – MaggiQ Apr 12 '16 at 11:11
5

I wanted to share my solution, in case someone is still struggling to achieve the desired affect.

My code is a refined version of @tonybrasunas's answer. It will add spaces every 4 characters, filter out non-numerical characters, fix character position, backspace, and only move the cursor forward if the character is valid, but still allow pushing with valid characters.


// FORMAT CC FIELD
//

$('#credit-card').on('input', function () {

  $(this).val(function (index, value) {
  
    // Store cursor position

    let cursor = $(this).get(0).selectionStart;
    
    // Filter characters and shorten CC (expanded for later use)
    
    const filterSpace = value.replace(/\s+/g, '');
    const filtered = filterSpace.replace(/[^0-9]/g, '');
    
    const cardNum = filtered.substr(0, 16);
    
    // Handle alternate segment length for American Express
    
    const partitions = cardNum.startsWith('34') || cardNum.startsWith('37') ? [4,6,5] : [4,4,4,4];
    
    // Loop through the validated partition, pushing each segment into cardNumUpdated
    
    const cardNumUpdated = [];
    let position = 0;
    
    partitions.forEach(expandCard => {
    
      const segment = cardNum.substr(position, expandCard);
      if (segment) cardNumUpdated.push(segment);
      position += expandCard;
      
    });
    
    // Combine segment array with spaces

    const cardNumFormatted = cardNumUpdated.join(' ');
    
    // Handle cursor position if user edits the number later
    
    if (cursor < cardNumFormatted.length - 1) {
        
      // Determine if the new value entered was valid, and set cursor progression
    
        cursor = filterSpace !== filtered ? cursor - 1 : cursor;
      
      setTimeout(() => {
      
        $(this).get(0).setSelectionRange(cursor, cursor, 'none');
        
      });
      
    }
    
    return cardNumFormatted;
    
  })
  
});

//
// END OF FORMAT CC FIELD

rileybarabash
  • 184
  • 2
  • 6
  • I like the space to appear after the section is typed, so I added this code after your `const cardNumFormatted = cardNumUpdated.join(' ');` changing that to a let: - ` let cardNumFormatted = cardNumUpdated.join(' '); let len = 0; for (let i = 0; i < partitions.length - 1; i++) { // -1 to skip last space len += partitions[i]; if (cardNumFormatted.length === len) { cardNumFormatted += ' '; break; } len++; //account for skipping previous spaces }; ` Could be shorter, but I understand this! – Jon Jan 14 '22 at 00:26
4

An Answer for 2021: Handling Backspace, Cursor Position, and American Express correctly

To handle Backspace and cursor arrows, we have to store the original cursor position and restore it with a setTimeout() when editing a spot anywhere other than the end of the string.

For American Express, we set up partitions to handle the 4-6-5 spacing format for Amex and the 4-4-4-4 spacing for all other cards. And we loop through them to add spaces.

$('#credit-card').on('keyup', function () {
  $(this).val(function (index, value) {

    const selectionStart = $(this).get(0).selectionStart;
    let trimmedCardNum = value.replace(/\s+/g, '');

    if (trimmedCardNum.length > 16) {
      trimmedCardNum = trimmedCardNum.substr(0, 16);
    }

    /* Handle American Express 4-6-5 spacing format */
    const partitions = trimmedCardNum.startsWith('34') || trimmedCardNum.startsWith('37') 
                       ? [4,6,5] 
                       : [4,4,4,4];
    
    const numbers = [];
    let position = 0;
    partitions.forEach(partition => {
      const part = trimmedCardNum.substr(position, partition);
      if (part) numbers.push(part);
      position += partition;
    });

    const formattedCardNum = numbers.join(' ');

    /* Handle caret position if user edits the number later */
    if (selectionStart < formattedCardNum.length - 1) {
      setTimeout(() => {
        $(this).get(0).setSelectionRange(selectionStart, selectionStart, 'none');
      });
    };

    return formattedCardNum;
  })
});

If you have a routine of your own to detect American Express numbers, use it. This simply examines the first two digits and compares to PAN/IIN standards.

I also posted an answer on how to do this in an Angular application.

Tony Brasunas
  • 4,012
  • 3
  • 39
  • 51
1

I solved this in Vue JS by creating a custom on-change handler. Rather than show it here, I will provide a link to that solution: Javascript: Set cursor position when changing the value of input

Based on my research, it is required to manage the position of the cursor yourself if you wish to fully-support editing with good UX.

pmrotule's vanilla JavaScript solution is great, but mine is drastically simpler, so it could be worthwhile to examine.

agm1984
  • 15,500
  • 6
  • 89
  • 113
1
 function cc_format(value) {
var v = value.replace(/\s+/g, '').replace(/[^0-9]/gi, '')
var matches = v.match(/\d{4,16}/g);
var match = matches && matches[0] || ''
var parts = []

for (i=0, len=match.length; i<len; i+=4) {
    parts.push(match.substring(i, i+4))
}

if (parts.length) {
    return parts.join(' ')
} else {
    return value
}

}

Use

$('#input-cc-number').on('keyup',function() {      
var cc_number = cc_format($(this).val());
$('#input-cc-number').val(cc_number);

});

krishna singh
  • 1,023
  • 1
  • 11
  • 15
0

Your issue at its core is that when an input field's value is updated using JavaScript, the cursor/selection position is set to the end of the string.

When user-input is appending to the end, this is fine, but if deleting, or inserting digits in the middle, this becomes quite annoying as you have observed. One way to deal with this would be to save and restore the cursor position within the field before and after each edit.

Crudely done:

$('#credit-card').on('keyup keypress change', function () {
    var s = this.selectionStart, e = this.selectionEnd;
    var oldleft = $(this).val().substr(0,s).replace(/[^ ]/g, '').length;
    $(this).val(function (index, value) {
        return value.replace(/\W/gi, '').replace(/(.{4})/g, '$1 ');
    });
    var newleft = $(this).val().substr(0,s).replace(/[^ ]/g, '').length;
    s += newleft - oldleft;
    e += newleft - oldleft;
    this.setSelectionRange(s, e);
});

This is not a full solution as the s and e positions will need updating if your code inserts/removes characters that result in these locations being moved.

You could also significantly optimise this by not setting val() if no update is required.

Steve Davies
  • 106
  • 6
  • Your solution has a bug after each space inserted: the caret position is set one step behind which mixes all the digits inserted. See https://jsfiddle.net/ot2t9zr4/18/ – pmrotule Oct 01 '16 at 09:24
  • You are absolutely correct, and your solution works well, but I will update the above for the "quick and dirty" solution. It still does not work perfectly if using copy/paste, but single digit entry should be fine. It also fixes the case when the user enters a 'space', or used backspace. – Steve Davies Oct 04 '16 at 09:07
0
$('.credit-card').keyup(function (e) {
    if (e.keyCode != 8) {
        if ($(this).val().length == 4) {
            $(this).val($(this).val() + " ");
        } else if ($(this).val().length == 9) {
            $(this).val($(this).val() + " ");
        } else if ($(this).val().length == 14) {
            $(this).val($(this).val() + " ");
        }
    }
});

This should work fine its just for card with format 4444 5555 2222 1111 and back space works correctly

Zer0
  • 1,580
  • 10
  • 28
gokul sri
  • 11
  • 1
0

To me @pmrotule answer was the best answer so far in this post. Good credit card spacing apparently helps conversion rate in shopping carts according to extensive research produced by Stripe.

Additionally however, setting the "type" attribute of the html input field to "tel" is also important, as well as inputmode="numeric" as well as setting the auto complete type cc-number, all also help improve checkout conversion rate. Especially on mobile when the user gets a numeric keyboard instead of qwerty.

Unfortunately this breaks @pmrotule's code. On another SO post I found out about Cleave.js which is a well tested library for this purpose & plays nicely with the tel input type: https://github.com/nosir/cleave.js

Putting it all together:

<input class="field" id="number" type="tel" inputmode="numeric" autocomplete="cc-number" />

with JS:

var cleave = new Cleave('#number', {
    creditCard: true,
    onCreditCardTypeChanged: function (type) {
    console.log('The detected card type is: '+type);
  }
});
Reece
  • 641
  • 7
  • 18