0
\$\begingroup\$

I'm developing a custom date input format to enhance user experience in scenarios where birthdays and expiration dates are inputted. Traditional date fields and date picker libraries often fall short in offering intuitive user interactions. My script has been designed to:

  • Enforce a month value range from 01 to 12.
  • Ensure day values stay within the 01 to 31 range.
  • Set the year value boundaries between 1900 and 2100.
  • If two digits year starting with 0 or 3 is entered year is assumed 1900s.
  • If two digits year starting with 4 - 9 is entered year is assumed to be 2000s.
  • Automatically advance from the month to day section when the first digit of the month exceeds 1.
  • Move the cursor to the year section when the initial digit of the day goes beyond 3.
  • Recognize a manually entered slash (/), and, if needed, precede it with a leading zero before transitioning to the next segment.
  • Filter out non-numeric inputs, with the sole exception being a manually entered slash (/).
  • Adjust a 00 entry for month or day to 01 for better consistency.
  • Permit the backspace functionality to clear fields without default autofill interruptions.

Added:

  • Form validation using pattern attribute, thanks morbusg
  • Allowed for pasting ISO yyyy-mm-dd formats, along with mm/dd/yyyy and m/d/yyyy, thanks J-H

My goal is to optimize user interactions, ensuring the underlying code is robust, reliable, and widely compatible across browsers.

const dateInputs = document.querySelectorAll('.date-input');

dateInputs.forEach(dateInput => {
    let lastValue = ""; // To keep track of the previous value for backspace detection

    dateInput.addEventListener('input', function() {
        let val = this.value;

        // If backspacing, skip the rest of the formatting
        if (lastValue && lastValue.length > val.length) {
            lastValue = val;
            return;
        }

        if (val.endsWith('/')) {
            val = val.slice(0, -1); // Remove the manually entered slash
            if (val.length === 1) {
                val = '0' + val + '/';
            } else if (val.length === 4) {
                val = val.slice(0, 3) + '0' + val.slice(3) + '/';
            }
        }

        val = val.replace(/\D/g, ''); // Remove any non-digit characters

        // Apply the MM/DD/YYYY format with constraints
        if (val.length === 1 && parseInt(val, 10) > 1) {
            val = '0' + val + '/';
        } else if (val.length >= 2) {
            let month = parseInt(val.substring(0, 2), 10);
            if (month === 0) month = 1;
            if (month > 12) month = 12;
            val = (month < 10 ? '0' + month : month) + '/' + val.substring(2);
        }

        if (val.length === 4 && parseInt(val.substring(3, 4), 10) > 3) {
            val = val.substring(0, 3) + '0' + val.substring(3) + '/';
        } else if (val.length >= 5) {
            let day = parseInt(val.substring(3, 5), 10);
            if (day === 0) day = 1;
            if (day > 31) day = 31;
            val = val.substring(0, 3) + (day < 10 ? '0' + day : day) + '/' + val.substring(5);
        }

        if (val.length === 7) {
            const yearStart = parseInt(val.substring(6, 7), 10);
            if (yearStart >= 4 && yearStart <= 9) { // If between 4 - 9 assume 1900's
                val = val.substring(0, 6) + '19' + val.substring(6);
            } else if (yearStart === 0 || yearStart === 3) { // If between 0 or 3 assume 2000's
                val = val.substring(0, 6) + '20' + val.substring(6);
            }
        } else if (val.length >= 10) {
            let year = parseInt(val.substring(6, 10), 10);
            if (year < 1900) {
                year = 1900;
            } else if (year > 2100) {
                year = 2100;
            }
            val = val.substring(0, 6) + year;
        }

        this.value = val;
        lastValue = val;

        dateInput.addEventListener('paste', function(e) {
            e.preventDefault();
            const clipboardData = e.clipboardData || window.clipboardData;
            let pastedData = clipboardData.getData('Text');

            if (isISOFormat(pastedData)) {
                this.value = convertFromISOFormat(pastedData);
            } 
            else if (isShortDateFormat(pastedData)) {
                this.value = convertFromShortFormat(pastedData);
            } 
            else if (isStandardDateFormat(pastedData)) {
                this.value = pastedData;
            } 
            else {
                // If it doesn't match any of the allowed formats, don't set the value.
                this.value = "";
                alert("Please paste a date in the correct format!");
                return;
            }

            let event = new Event('input', {
                'bubbles': true,
                'cancelable': true
            });
            dateInput.dispatchEvent(event); // manually dispatch input event to trigger your input handler after paste
        });

    });

    // Prevent users from entering non-digit characters, except slash
    dateInput.addEventListener('keydown', function(e) {
        if (!isAllowedDateKeyEvent(e)) {
            e.preventDefault();
        }
    });
});

// Check if it's in yyyy-mm-dd format
function isISOFormat(date) {
    const isoPattern = /^\d{4}-\d{2}-\d{2}$/;
    return isoPattern.test(date);
}

// Convert yyyy-mm-dd to MM/DD/YYYY
function convertFromISOFormat(isoDate) {
    const parts = isoDate.split('-');
    return `${parts[1]}/${parts[2]}/${parts[0]}`;
}

// Check if it's in m/d/yyyy format
function isShortDateFormat(date) {
    const shortDatePattern = /^\d{1,2}\/\d{1,2}\/\d{4}$/;
    return shortDatePattern.test(date);
}

// Convert m/d/yyyy to MM/DD/YYYY
function convertFromShortFormat(shortDate) {
    const parts = shortDate.split('/');
    const month = parts[0].padStart(2, '0');
    const day = parts[1].padStart(2, '0');
    const year = parts[2];
    return `${month}/${day}/${year}`;
}

// Check if it's in mm/dd/yyyy format
function isStandardDateFormat(date) {
    const standardDatePattern = /^\d{2}\/\d{2}\/\d{4}$/;
    return standardDatePattern.test(date);
}

function isAllowedDateKeyEvent(e) {
    let charCode = e.keyCode;

    // Allow CTRL key or CMD key on Mac (e.metaKey)
    if (e.ctrlKey || e.metaKey) {
        return true;
    }

    if (charCode > 31 && (charCode < 48 || charCode > 57) && charCode !== 37 && charCode !== 39 && charCode !== 8 && charCode !== 46 && charCode !== 191) {
        return false;
    }

    return true;
}
<input type="text" class="date-input" placeholder="mm/dd/yyyy" pattern="\d{2}/\d{2}/\d{4}" inputmode="numeric">

\$\endgroup\$
7
  • 2
    \$\begingroup\$ quick tip: look into the "pattern" attribute \$\endgroup\$
    – morbusg
    Oct 28, 2023 at 13:46
  • 1
    \$\begingroup\$ Thank you for the very nice list of requirements. Three of them seem to be UX nightmares, making it hard for a touch typist looking at a paper date record to predict what date the form winds up with: (1) "advance ... when the first digit of the month exceeds 1", (2) "... when the initial digit of the day goes beyond 3", (3) "adjust a 00 entry". It drives me crazy that in many forms I will copy-n-paste a telno, CC number or date to assure accuracy, and then the form mangles it. I am sad that your mmddyyyy form rejects pasted ISO yyyy-mm-dd dates even though they're completely unambiguous. \$\endgroup\$
    – J_H
    Oct 28, 2023 at 16:35
  • 1
    \$\begingroup\$ Enter a valid date 10/29/2023 then change the day to 30 by replacing the 29. Your code makes a mess of it (my attempt deleting the 29 and without looking enter 30) the date becomes 10/31/1900. Not sure if your code should be considered working and thus may be off topic? \$\endgroup\$
    – Blindman67
    Oct 28, 2023 at 17:48
  • \$\begingroup\$ @J_H - thoughts on the edit? \$\endgroup\$
    – tony
    Oct 29, 2023 at 0:51
  • \$\begingroup\$ I thank you for the update. I doubt I'm qualified to evaluate it. I confess I am merely a Software Engineer, and not a Javascript jockey. The "paste" listener makes me nervous, but IDK perhaps that is the appropriate way to handle it. Let's leave the ISO "stretch goal" behind us for the moment. The "I will behave quite differently based on whether you type 3" impressed me as trouble, since a pre-determined (paste) string of valid user input characters would be treated differently based on such details. I had in mind unconditionally accepting 1 input char, then 1 msec later "fix it up". \$\endgroup\$
    – J_H
    Oct 29, 2023 at 1:53

1 Answer 1

2
\$\begingroup\$

Unsafe input modification

There are way too many issues with this function for it to be safe to use in the wild.

The main reason is when a client modifies the data (change an entered date). Your function makes changes to the input away from the caret, this should never happen and may not be noticed by the client, especially if "tab" or "enter" moves focus to the next input.

There are also many more date formats than the US scheme "mm/dd/yyyy"

Most common is "dd/mm/yyyy" Which is incompatible with your function. Don't expect clients to actually read the placeholder (which is gone on the first entered character).

Locale dates

Let the browser handle the date format, just use the input type "date"

Example

<input type="date" min="1900-01-01" max="2100-01-01"/>

dateInput.addEventListener("input", (e) => {
  console.clear();
  console.log("Input string.: " + dateInput.value);  // Show value as stored in imput element
  const inputDate = new Date(dateInput.value);       // Create the correct date defined by clients locale setup
  console.log("Date object..: " + inputDate);
  console.log("Date value...: " + inputDate.valueOf());
  console.log("Date JSON....: " + inputDate.toJSON());
  console.log("US format....: " + inputDate.toLocaleString("en-US"));
  console.log("Common.......: " + inputDate.toLocaleString("en-AU"));
});
<input type="date" id="dateInput" min="1900-01-01" max="2100-01-01"/>

\$\endgroup\$
1
  • \$\begingroup\$ Yeah but sometimes a date picker isn't optimal and a user wants to fill in a date. There in lies the issue. \$\endgroup\$
    – tony
    Nov 17, 2023 at 2:33

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.