summaryrefslogtreecommitdiffhomepage
path: root/gui/src/renderer/components/FormattableTextInput.tsx
blob: f6c259c4c4cf60edd3057e61d14bf556f270004c (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
import React, { useCallback, useEffect } from 'react';

import { useCombinedRefs, useStyledRef } from '../lib/utilityHooks';

interface IFormattableTextInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  allowedCharacters: string;
  separator: string;
  uppercaseOnly?: boolean;
  maxLength?: number;
  groupLength: number;
  addTrailingSeparator?: boolean;
  handleChange: (value: string) => void;
}

function FormattableTextInput(
  props: IFormattableTextInputProps,
  forwardedRef: React.Ref<HTMLInputElement>,
) {
  const {
    addTrailingSeparator,
    allowedCharacters,
    groupLength,
    handleChange,
    maxLength,
    separator,
    uppercaseOnly,
    value,
    ...otherProps
  } = props;

  const ref = useStyledRef<HTMLInputElement>();
  const combinedRef = useCombinedRefs(ref, forwardedRef);

  const unformat = useCallback(
    (value: string) => {
      const correctCaseValue = uppercaseOnly ? value.toUpperCase() : value;
      return correctCaseValue.match(new RegExp(allowedCharacters, 'g'))?.join('') ?? '';
    },
    [uppercaseOnly, allowedCharacters],
  );

  const format = useCallback(
    (value: string, addTrailingSeparator?: boolean) => {
      let formatted = value.match(new RegExp(`.{1,${groupLength}}`, 'g'))?.join(separator) ?? '';

      if (
        addTrailingSeparator &&
        value.length > 0 &&
        value.length % groupLength === 0 &&
        (!maxLength || maxLength > value.length)
      ) {
        formatted += separator;
      }

      return formatted;
    },
    [groupLength, separator, maxLength],
  );

  const onBeforeInput = useCallback(
    (event: Event) => {
      const { inputType, data, target } = event as InputEvent;

      if (ref.current) {
        const inputElement = target as HTMLInputElement;
        const oldValue = inputElement.value;

        const selectionStart = inputElement.selectionStart ?? oldValue.length;
        const selectionEnd = inputElement.selectionEnd ?? selectionStart;
        const emptySelection = selectionStart === selectionEnd;
        const beforeSelection = unformat(oldValue.slice(0, selectionStart));
        const afterSelection = unformat(oldValue.slice(selectionEnd));

        let unformattedData = unformat(data ?? '');
        // Only allow adding data that fits into the max length.
        if (maxLength) {
          const charactersLeft = maxLength - beforeSelection.length - afterSelection.length;
          unformattedData = unformattedData.slice(0, charactersLeft);
        }

        let newValue: string;
        let caretPosition: number;
        if (inputType === 'deleteContentBackward' && emptySelection && beforeSelection.length > 0) {
          // This is triggered when pressing backspace without a selection
          newValue = beforeSelection.slice(0, -1) + afterSelection;
          caretPosition = format(beforeSelection + unformattedData, false).length - 1;
        } else if (inputType === 'deleteContentForward' && emptySelection) {
          // This is triggered when pressing delete without a selection
          newValue = beforeSelection + afterSelection.slice(1);
          caretPosition = format(beforeSelection + unformattedData, true).length;
        } else {
          newValue = beforeSelection + unformattedData + afterSelection;
          caretPosition = format(beforeSelection + unformattedData, true).length;
        }

        const formattedValue = format(newValue, addTrailingSeparator);
        caretPosition = Math.min(caretPosition, formattedValue.length);

        // The new value can't be set before the browser has changed the content of the input
        // element since that would result in the change being made twice. Another alternative would
        // be to call `event.preventDefault()` but that prevents other side effects such as the
        // scrolling of the input content when overflowing.
        ref.current.addEventListener(
          'input',
          () => {
            inputElement.value = formattedValue;
            inputElement.selectionStart = inputElement.selectionEnd = caretPosition;
            handleChange(newValue);
          },
          { once: true },
        );
      }
    },
    [unformat, format, handleChange, addTrailingSeparator],
  );

  // React doesn't fully support onBeforeInput currently and it's therefore set here.
  useEffect(() => {
    ref.current?.addEventListener('beforeinput', onBeforeInput);
    return () => ref.current?.removeEventListener('beforeinput', onBeforeInput);
  }, [onBeforeInput]);

  // Use value provided in props if it differs from current input value.
  useEffect(() => {
    if (typeof value === 'string' && ref.current && unformat(ref.current.value) !== value) {
      ref.current.value = format(value, addTrailingSeparator);
    }
  }, [format, value, addTrailingSeparator]);

  return <input ref={combinedRef} type="text" {...otherProps} />;
}

export default React.memo(React.forwardRef(FormattableTextInput));