Structured Content 2022: Join our conference to explore fresh perspectives on content and digital experiences →

Advanced Numeric Input

By Fabien Franzen

Parse, format and mask numeric values with ease

components/NumericInput.js

import React, { useState, useCallback, useMemo, useEffect } from 'react';
import { useId } from '@reach/auto-id';
import { FormField } from '@sanity/base/components';
import { TextInput } from '@sanity/ui';
import PatchEvent, { set } from 'part:@sanity/form-builder/patch-event';
import NumberFormat from 'react-number-format';

const INPUT_PROPS = [
  'value',
  'onChange',
  'onKeyDown',
  'onMouseUp',
  'onFocus',
  'onBlur',
];

const NUMBER_FORMAT_PROPS = Object.keys(
  omit(NumberFormat.propTypes, [
    'displayType',
    'defaultValue',
    'customInput',
    'getInputRef',
    'onValueChange',
    ...INPUT_PROPS,
  ])
);

const NumberInput = (props) => {
  const { id, type, markers, readOnly, forwardedRef } = props;
  const forwardedProps = pick(props, INPUT_PROPS);

  const errors = useMemo(() => {
    return markers.filter(
      (marker) => marker.type === 'validation' && marker.level === 'error'
    );
  }, [markers]);

  return (
    <TextInput
      id={id}
      ref={forwardedRef}
      customValidity={errors && errors.length > 0 ? errors[0].item.message : ''}
      readOnly={Boolean(readOnly)}
      placeholder={type.placeholder}
      inputMode={props.isDecimal ? 'decimal' : 'numeric'}
      {...forwardedProps}
    />
  );
};

const NumericInput = React.forwardRef((props, forwardedRef) => {
  const { type, markers, level, presence, onChange } = props;

  const numberFormatProps = {
    allowEmptyFormatting: true,
    allowedDecimalSeparators: ['.', ','],
    ...pick(type.options, NUMBER_FORMAT_PROPS),
  };

  const forwardedProps = omit(props, ['value', 'onChange']);

  // Use local state and effects to prevent issue with
  // NumberFormat triggering additional onChangeValue
  // calls, causing unwanted patches (reverting values).
  const [value, setValue] = useState(props.value ?? 0);

  const onValueChange = useCallback(
    ({ floatValue }) => {
      setValue(floatValue);
      if (props.value !== floatValue) {
        onChange(PatchEvent.from(set(Number(floatValue))));
      }
    },
    [onChange, props.value]
  );

  // Required for external changes, like reverting a document.
  useEffect(() => {
    if (props.value !== value) {
      setValue(typeof props.value === 'undefined' ? 0 : props.value);
    }
  }, [props.value, value]);

  const id = useId();

  return (
    <FormField
      inputId={id}
      level={level}
      title={type.title}
      description={type.description}
      __unstable_markers={markers}
      __unstable_presence={presence}
    >
      <NumberFormat
        id={id}
        forwardedRef={forwardedRef}
        customInput={NumberInput}
        value={value}
        onValueChange={onValueChange}
        isDecimal={numberFormatProps.decimalScale !== 0}
        {...forwardedProps}
        {...numberFormatProps}
      />
    </FormField>
  );
});

export default NumericInput;

// Helpers

function pick(obj, keys) {
  return Object.fromEntries(
    Object.entries(obj ?? {}).filter(([key]) => keys.includes(key))
  );
}

function omit(obj, keys) {
  return Object.fromEntries(
    Object.entries(obj ?? {}).filter(([key]) => !keys.includes(key))
  );
}

types/numeric.js

import NumericInput from '../components/NumericInput';

export default {
  title: 'Numeric',
  name: 'numeric',
  type: 'number',
  inputComponent: NumericInput,
  options: {
    decimalScale: 2,
    fixedDecimalScale: false,
  },
};

types/float.js

import NumericInput from '../components/NumericInput';

export default {
  title: 'Float',
  name: 'float',
  type: 'number',
  inputComponent: NumericInput,
  options: {
    decimalScale: 2,
    fixedDecimalScale: true,
  },
};

types/integer.js

import NumericInput from '../components/NumericInput';

export default {
  title: 'Integer',
  name: 'integer',
  type: 'number',
  inputComponent: NumericInput,
  validation: (Rule) => Rule.precision(0).positive(),
  options: {
    decimalScale: 0,
    fixedDecimalScale: true,
    allowNegative: false,
  },
};

types/price.js

import NumericInput from '../components/NumericInput';

export default {
  title: 'Price',
  name: 'price',
  type: 'number',
  inputComponent: NumericInput,
  validation: (Rule) => Rule.positive(),
  options: {
    decimalScale: 2,
    fixedDecimalScale: true,
    allowNegative: false,
    decimalSeparator: ',',
    thousandSeparator: '.',
    prefix: '€ ',
  },
};

types/percentage.js

import NumericInput from '../components/NumericInput';

export default {
  title: 'Percentage',
  name: 'percentage',
  type: 'number',
  inputComponent: NumericInput,
  validation: (Rule) => Rule.positive(),
  options: {
    decimalScale: 0,
    fixedDecimalScale: true,
    allowNegative: false,
    suffix: ' %',
  },
};

This custom input leverages react-number-format to parse, format and mask numeric values. The value is stored as a float.

Most options of react-number-format can be used, and a few common numeric type preset schemas are provided as well.

Contributor