Keyboard Input of Date and Time According to Language Conventions

Giancarlo Radaelli
JavaScript in Plain English
7 min readDec 14, 2021

--

A React component for keyboard entry of dates, that follows the locale conventions of the browser language.

Rationales

In form fields for editing, dates are usually associated with a date picker.

The selection of date and time are visually driven by the different elements of the selector.

The chosen date is then displayed in the field following the conventions of the language used by the browser.

However, fields should also provide keyboard input to make it accessible to users who, for various reasons, are unable to use a pointing device.

The keyboard input can also speed up the form fill-in for data entry applications.

Dates should be edited in the proper format of each language. In the following the JavaScript Date object methods and the internationalization API (Intl) will be used to implement a field that provide this feature.

There are three points in the field implementation where the correct application of the language conventions comes into play:

  • The display of date and time
  • The parsing of the user input
  • The visualization of the placeholder (this is important to inform the user about the recognized language conventions)

Assumptions

The following assumptions are fairly common when dealing with date and time entry:

  • Date and time parts are entered as numbers
  • Date components are separated by one of the following char: /, -, or blank
  • Date and time parts are separated by a blank space
  • Time components are separated by :
  • Year is always indicated with 4 digit

Variability of date formats

With the previous assumptions, the format variability is limited to the following two areas:

  • Position of date components: DD/MM/YYYY, YYYY/MM/DD or MM/DD/YYYY
  • Time indicated with/without the day period (AM/PM)

Date constructor

While it is easy to tell if a date format has the year in the first or last position, it is impossible to discriminate between the two formats that differ in the position of the month (DD/MM/YYYY and MM/DD/YYYY).

The date constructor that takes a string as input interprets the date ‘1/2/2022’ in only one way: it is the second of January (not the first of February).
The constructor cannot be used with date formats that have the day as the first component (DD/MM/YYYY).

To avoid this problem, the parser (see below) will use the Date constructor which takes as input the numeric values of all date parts.

Browser language

The field will manage the dates following the conventions of the language adopted by the browser.

This language is defined by the global variable navigator.language.

To change this value just modify the browser language option.

Recognize the locale conventions

Given a language, it is easy to recognize its locale conventions using the Intl API.

The first step is to declare the types and parts of date&time strings that we want to use. Accordingly, with the previous assumption, we are interested in numeric values of each date&time part:

const opt = {
year: ‘numeric’,
month: ‘numeric’,
day: ‘numeric’,
hour: ‘numeric’,
minute: ‘numeric’,
seconds: 'numeric'
}

With these options it is possible to create a DateTimeFormat object that can handle the date locale conventions:

const dateTimeFormat = 
new Intl.DateTimeFormat(navigator.language, opt)

From the DateTimeFormat object we can retrieve the date and time parts of any date string:

const dateParts = localeFormat.formatToParts(new Date(0))                     ||
\/
[
{type: ‘day’, value: ‘1’},
{type: ‘literal’, value: ‘/’},
{type: ‘month’, value: ‘1’},
{type: ‘literal’, value: ‘/’},
{type: ‘year’, value: ‘1970’},
{type: ‘literal’, value: ‘, ‘},
{type: ‘hour’, value: ‘01’},
{type: ‘literal’, value: ‘:’},
{type: ‘minute’, value: ‘00’}
]

Note that the pc timezone was UTC+1:00, so hour is 01 and not 00.

From the previous vector it is possible to build a map of date parts indexes (in this case we are not interested in separator literals):

const partIndex = dateParts
.reduce((acc, e, i) => ({ ...acc, [e.type]: i + 1 }), {})
||
\/
{
day: 1,
month: 3,
year: 5,
hour: 7,
minute: 9,
literal:8
}

Invalid dates

One of the state variables of the date field (see below) is the date object corresponding to the input string.

When the input string does not represent a valid date, the value of this state variable shall be an invalid date.

To create an invalid date it is sufficient to invoke the constructor with a string that does not represent a date. But to make explicit the intent, the following idiomatic expression will be used:

new Date(undefined)

The test to detect an invalid date is very simple:

isNaN(date.getTime())

or in a more concise way:

isNaN(date)

Display date and time

This is the most simple task among the three listed at the beginning: the translation of a date into a locale string is completely supported by Date methods:

export const dateToLocaleString = (date) =>
!date || isNaN(date.getTime())
? ''
: date.toLocaleDateString(navigator.language) + ' ' +
date.toLocaleTimeString(navigator.language)

Date placeholder

The placeholder string can be easily built using the dateParts object previously defined.

The date parts will be displayed with four digits for the year and two digits for the month and day, regardless of the locale indication on leading zeros.
Instead, the parser (see below) will force the leading zero only for minutes and seconds.

const toUpper = (t, v) => t === 'literal'
? v
: t[0] === 'y'
? 'YYYY'
: t[0].toUpperCase() + t[0].toUpperCase()
const DATE_FORMAT_NO_TIME = dateParts
.slice(0, 5)
.reduce((a, e) => a + toUpper(e.type, e.value), '')
const DATE_FORMAT = DATE_FORMAT_NO_TIME +
( dateParts.length < 13
? ' HH:mm:ss'
: ' hh:mm:ss A'
)

Locale date and time parser

As with more complex compilers/transpilers, parsing a local date string and translating it into a Date object is a two-step process:

  • Token recognition
  • Semantic checks, translation and syntax error management

Token recognition

For simple cases like this, tokenization can be achieved in one fell swoop using a simple regular expression. Actually, to properly handle the year position, two are used:

const dateReg = /^(\d?\d)([-/. ])(\d?\d)([-/. ])(\d{4})( +(\d?\d):(\d?\d)(:(\d?\d))?( +([AP]M))?)?$/const dateRegYearFirst = /^(\d{4})([-/. ])(\d?\d)([-/. ])(\d?\d)( +(\d?\d):(\d?\d)(:(\d?\d))?( +([AP]M))?)?$/

In the date part, the capture groups include also the separators in order to keep the correspondence with the partIndex object (e.g. the day group in the matching vector is at the partIndex.day position).

Input of seconds is not mandatory (:(\d?\d))?. The same technique can be used to make minutes not mandatory.

Also the whole time sub-string can be omitted (see the optional group controlled by the last question mark in the reg exps). In this case the translation section of the parser (simply the Date constructor) will set the time to 00:00:00.

Semantic checks, translation and error management

Using the object partIndex and the array of tokens extracted from the locale string, it is simple to perform checks, translate a correct string or manage the error.

The full parser is listed below:

Lines 4–6 invoke the tokenizer.

Lines 9–17 contain the semantic checks. Lines 14, 16 enforce the leading zero (when needed) for minutes and seconds.

Line 19 is the error management: just returns an invalid date.

Lines 22–29 translate the set of tokens into the parser result.

React field

Now that we have defined the date features needed to manage the locale strings (containing numeric elements), we can put them at work inside a react component.

State variables

The component uses three state variables:

  • The string displayed in the input element. The input element will be a controlled component, so we need a variable to store the input value.
  • The date object that represents the field value exported to the parent component (usually a form component that manages a set of fields).
  • The error state. It is the string displayed to notify the error, but also the indicator of the field error state: empty string => no error; not empty string => field in error
const [date, setDate] = useState(new Date(undefined))
const [value, setValue] = useState('')
const [error, setError] = useState('')

Event callbacks

The behavior of the field is defined by two callbacks: the callback that handles field changes and the callback invoked when the field loses focus.

const changeDate = useCallback(
(ev) => {
const date = localeStringToDate(ev.target.value)
setDate(date)
setValue(ev.target.value)
setError('')
props.onChange(date)
},
[props.onChange]
)
const validateDate = useCallback(
() => {
if (value && isNaN(date)) {
setError('error')
} else {
setValue(dateToLocaleString(date))
setError('')
}
},
[date, value]
)

The changeDate callback clears the error string (the error if any is displayed only when the field loses focus).

The date object returned by localeStringToDate (valid or not) is used to set the internal state variable and is passed to the callback of the parent component (props.onChange).

Whatever the user has typed is used to set the internal state variable that controls the input element.

The validateDate callback sets an error message if the date is invalid, otherwise it replaces the user input with the correct locale string.

Render the component

The following code snippet should be added to the previous ones to create the functional React component:

const stateToColor = error
? 'red'
: value && isNaN(date.getTime())
? 'orange'
: 'black'
return (
<>
<input
type="text"
placeholder={DATE_FORMAT}
value={value}
onChange={changeDate}
onBlur={validateDate}
style={{ borderColor: stateToColor,
outlineColor: stateToColor}}
/>
<div style={{ color: 'red' }}>{error}</div>
</>
)

Try yourself

You can try the previous code on https://codesandbox.io/s/localedatefield-87cdh

When seconds are useless

It is simple to modify the previous code to adapt the locale strings, the place holder and the parser to different needs.

For example, in the case where seconds are not significant and should not be used (internally the date constructor will set them to 0) the following simple changes should be applied:

  • Locale string: it is possible to remove the seconds taking care to consider the two different locale time variants (with/without the time period):
date.toLocaleTimeString(navigator.language)
.replace(/:00( [AP]M)?$/, '$1')
  • The placeholder modification is trivial:
const DATE_FORMAT = DATE_FORMAT_NO_TIME +
( dateParts.length < 13
? ' HH:mm'
: ' hh:mm A'
)
  • Semantic checks of the parser should be modified to test if the seconds token is truthy: lines 15, 16 should be replaced by dateToken[10]||

More content at plainenglish.io. Sign up for our free weekly newsletter here.

--

--