✨Discover storytelling in the AI age with Pixar's Matthew Luhn at Sanity Connect, May 8th—register now

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