How we internationalized our number field

By Aaron R Miller

Number fields are commonly used form components, but are frequently not a great user experience. They often lack support for advanced formatting, such as currency and unit values, and do not provide a localized experience for users around the world. In this post, we'll discuss how we approached building our number field component with support for formatting and internationalization in mind.

What is a number field?#


A number field allows users to input and edit numeric values. For example, quantities, dimensions, currencies, percentages, or unit values may be edited with a number field. In addition to allowing the user to enter these values with their keyboard, number fields also often support incrementing and decrementing the value using stepper buttons, the arrow keys, or by scrolling with the mouse.

Values such as credit card and phone numbers are not typically represented by a number field because while they do contain numbers, they are not incrementable, and have additional formatting and validation requirements.

Formatting#

Number fields can be used to represent many different types of numeric values, each of which have unique formatting requirements. For example, decimals can be rounded to a particular number of decimal places, percentages display a percent sign along with the number, currencies display a currency symbol or code, and dimension values may have a unit associated with them. In addition, formatting requirements for these values may differ between countries, languages, and numbering systems used around the world. For example, in the US, we use the “.” character to represent a decimal point, while in Germany, the “,” character is used.

We use the Intl.NumberFormat API built into browsers to handle all of these formatting requirements across all locales. Our number field component supports most of the formatting options that the Intl API supports, which ensures that it is international friendly and supports many common formatting options out of the box. The browser currently only provides formatting support, however, not parsing support, so we built a custom number parser using information from the formatter. We’ll discuss how this works in detail later in the post.

<NumberField
  label="Transaction amount"
  defaultValue={45}
  minValue={0}
  formatOptions={{
    style: 'currency',
    currency: 'EUR'
  }}
/>
<NumberField
  label="Transaction amount"
  defaultValue={45}
  minValue={0}
  formatOptions={{
    style: 'currency',
    currency: 'EUR'
  }}
/>
<NumberField
  label="Transaction amount"
  defaultValue={45}
  minValue={0}
  formatOptions={{
    style: 'currency',
    currency: 'EUR'
  }}
/>

Stepping#

Many number fields also support incrementing and decrementing their value using stepper buttons, arrow keys, mouse scroll wheel, or gestures in some screen readers. We support this via a step prop, which controls how much to increment or decrement by each time the buttons or arrow keys are pressed. The step also determines how to round the value to the nearest step on blur if the user enters a value that isn’t on a step boundary.

One area of complexity here is dealing with floating point rounding errors. All JavaScript numbers are double precision floating point, which means when you add or subtract two numbers, you can get rounding errors. For example, 0.1 + 0.2 === 0.30000000000000004. This isn’t really what users expect, however, so to fix this, we determine how many decimal places there are, multiply to convert the two numbers to integers, perform the addition or subtraction, and then divide again to get a decimal. A similar fix is necessary to perform step snapping on blur.

<NumberField
  label="Exposure"
  formatOptions={{
    signDisplay: 'exceptZero',
    minimumFractionDigits: 1,
    maximumFractionDigits: 2
  }}
  defaultValue={0}
  step={0.1}
/>
<NumberField
  label="Exposure"
  formatOptions={{
    signDisplay: 'exceptZero',
    minimumFractionDigits: 1,
    maximumFractionDigits: 2
  }}
  defaultValue={0}
  step={0.1}
/>
<NumberField
  label="Exposure"
  formatOptions={{
    signDisplay:
      'exceptZero',
    minimumFractionDigits: 1,
    maximumFractionDigits: 2
  }}
  defaultValue={0}
  step={0.1}
/>

Allowed characters#

A number field should help the user enter a valid number and avoid accidental input. In order to do this, we only allow the user to type characters that meet the formatting requirements. For example, when the number is a percentage, we allow only numerals and the percent sign, and all other characters are ignored. The allowed characters are based on the formatting options as well as the current locale.

In addition, we also support several different numbering systems, including the Latin, Arabic, and Han positional decimal systems. There are many different ways of entering numbers, including different hardware keyboard layouts, and various input method editors such as Pinyin, which uses combinations of Latin characters to represent Chinese logograms. These are supported via composition events, which the browser fires as the user enters each Latin character. While these characters by themselves are not valid numbers, we cannot validate them until the sequence is complete and the Latin characters are replaced by the Chinese logogram.

Entering the number 21 in the Han positional decimal system using the iOS Pinyin keyboard

Mobile#

On mobile devices, the keyboard should be as helpful as possible, providing only the characters that can be entered into the field. This can vary based on the formatting options provided to the number field. For example, if the minimum value is greater than zero, no minus key should be displayed, and if fractional values are not allowed, then no decimal point should appear.

The inputMode DOM attribute can be used to control the on screen keyboard shown by mobile devices. However, standard numeric keyboards vary across devices and operating systems making it difficult to provide a unified experience. For example, the iOS numeric keyboard does not include a minus sign at all, which means we must use a full text keyboard instead. This is unfortunately not an ideal experience, but it is the only way to allow a user to enter all possible numbers.

Iphone screenshot of example NumberField inputMode="numeric"
With inputMode="numeric", iOS does not include a minus key or a decimal point.
Iphone screenshot of example NumberField inputMode="decimal"
Wth inputMode="decimal", iOS does include a decimal point, but no minus key.
Iphone screenshot of example NumberField inputMode="text"
inputMode="text" is the only way to get a minus key on iOS.
Android screenshot of example NumberField inputMode="decimal"
Android does not include a negative sign with inputMode="decimal".
Android screenshot of example NumberField inputMode="numeric"
Android includes both a negative sign and decimals with inputMode="numeric".

In order to optimize the experience as much as possible, we detect the operating system and switch the value of the inputMode attribute according to the formatting options. For example, negative numbers are allowed, we use inputMode="text" on iOS, but inputMode="numeric" on Android.

Problems with native number inputs#


When considering how to implement a number field, the obvious solution is the built in <input type="number"> element supported in browsers. However, we ran into several issues that led us to avoid it.

  1. Most browser implementations do not allow the level of formatting that we require. They typically only support decimals, and don’t allow formatting options for number of decimal points, or display as a percentage, currency, or unit value.
  2. In addition, most browsers don’t support numbering systems other than Latin, and may completely block input of any characters other than Latin numerals, minus and plus signs, decimal points, and the letter e for exponential notation.
  3. Browser implementations are also very inconsistent. Formatting and rounding behavior may vary, along with the UI presented to the user. Along with the keyboard differences mentioned previously, some browsers have steppers and others do not, and the mobile experience for incrementing and decrementing numbers is often lacking. In addition, these steppers often cannot be styled to match our design requirements.

For these reasons, we decided to use an <input type="text"> element along with the inputMode attribute to specify the mobile keyboard, and a custom ARIA role description, rather than <input type="number">.

Internationalization#


Internationalization is an especially important feature for components like number field. Users around the world expect to enter numbers using their local numbering system and formats, so we needed to implement a number parser that could handle many numbering systems and locale combinations.

Locales and numbering systems#

A locale represents a set of preferences for users in a particular part of the world, such as the language, currency, and number format. For example, in the en-US locale, the default language is English and the decimal character is a period, but in the de-DE locale, the default language is German, and the decimal character is a comma.

A numbering system is a way of representing numbers using written characters. For example, in the Latin numbering system, the number twelve is represented as “12”, and in the Arabic decimal system, it is “١٢”. Most commonly used numbering systems are decimal based, which means they have ten numeral symbols that are combined based on their position. An example of a non-positional numbering system is the Roman numeral system, in which digits are combined by addition or subtraction. Currently, we only support base-10 decimal systems, since these are most commonly used and the simplest to parse.

While a locale may have a default numbering system associated with it, users may choose to use a different one. For example, the ar-AE locale defaults to Arabic numerals, but users may still wish to enter a Latin number. Our number field automatically detects the numbering system of the characters entered by the user, and parses it accordingly.

Localized number parsing#

JavaScript’s parseFloat function only handles Latin numbers and US-style decimals, so in order to parse localized numbers we had to get creative. We use an Intl.NumberFormat object to format each digit in a locale/numbering system and generate a map between numeral characters and number values. We also use the number formatter to determine the allowed set of non-numeral characters such as the decimal point, group separator, and minus sign for the locale. This gives us enough information to validate and parse user input.

The parsing process consists of removing all non-numeric characters, and replacing numerals, decimal points, and minus signs in the input string with their Latin equivalents. Then we can simply use the parseFloat function as usual. There is also some additional sanitization required in some locales where a formatted character like a minus sign may not be available on a user's keyboard. In these cases, we want to allow both the formatted character as well as the character on the keyboard to ensure numbers can both be typed manually and pasted from a pre-formatted value.

This approach of using a number formatter to generate a parser avoids needing to download any heavy locale data, since it can rely on the data the browser already has. This means it works with many number formats, locales, and numbering systems out of the box and automatically supports more options as browsers add them.

Conclusion#


In this post, we covered how a number field can be internationalized and support advanced formatting, and how we improved the experience on mobile. If you need to implement localized number parsing in your own apps or components, check out the @internationalized/number package on npm. And if you use the useNumberField hook in React Aria, or the NumberField component in React Spectrum, all of this is built in.