summaryrefslogtreecommitdiffhomepage
path: root/gui/src
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2020-12-10 13:45:58 +0100
committerOskar Nyberg <oskar@mullvad.net>2020-12-10 13:45:58 +0100
commit4aa77f22a715758c77888f6817d11068c071d9c1 (patch)
tree93edd7cf9ad9fa4c5c96dc9d781cda2472adf00c /gui/src
parenta832f6f3628272166d7232e839b4fc5091ecdafd (diff)
parent6cb7b3d380f72731f431783a656431ef71d9b0ff (diff)
downloadmullvadvpn-4aa77f22a715758c77888f6817d11068c071d9c1.tar.xz
mullvadvpn-4aa77f22a715758c77888f6817d11068c071d9c1.zip
Merge branch 'format-input-value'
Diffstat (limited to 'gui/src')
-rw-r--r--gui/src/renderer/components/FormattableTextInput.tsx132
-rw-r--r--gui/src/renderer/components/Login.tsx8
-rw-r--r--gui/src/renderer/components/LoginStyles.tsx3
-rw-r--r--gui/src/renderer/components/RedeemVoucher.tsx14
-rw-r--r--gui/src/renderer/components/RedeemVoucherStyles.tsx3
5 files changed, 151 insertions, 9 deletions
diff --git a/gui/src/renderer/components/FormattableTextInput.tsx b/gui/src/renderer/components/FormattableTextInput.tsx
new file mode 100644
index 0000000000..e1ea604fb9
--- /dev/null
+++ b/gui/src/renderer/components/FormattableTextInput.tsx
@@ -0,0 +1,132 @@
+import React, { useCallback, useEffect, useRef } from 'react';
+import { useCombinedRefs } 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 = useRef() as React.RefObject<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));
diff --git a/gui/src/renderer/components/Login.tsx b/gui/src/renderer/components/Login.tsx
index 8d9f96c14e..6118f7ac8e 100644
--- a/gui/src/renderer/components/Login.tsx
+++ b/gui/src/renderer/components/Login.tsx
@@ -128,14 +128,13 @@ export default class Login extends React.Component<IProps, IState> {
}
};
- private onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+ private onInputChange = (accountToken: string) => {
// reset error when user types in the new account number
if (this.shouldResetLoginError) {
this.shouldResetLoginError = false;
this.props.resetLoginError();
}
- const accountToken = event.target.value.replace(/[^0-9]/g, '');
this.props.updateAccountToken(accountToken);
};
@@ -255,12 +254,15 @@ export default class Login extends React.Component<IProps, IState> {
onSubmit={this.onSubmit}>
<StyledAccountInputBackdrop>
<StyledInput
+ allowedCharacters="[0-9]"
+ separator=" "
+ groupLength={4}
placeholder="0000 0000 0000 0000"
value={this.props.accountToken || ''}
disabled={!this.allowInteraction()}
onFocus={this.onFocus}
onBlur={this.onBlur}
- onChange={this.onInputChange}
+ handleChange={this.onInputChange}
autoFocus={true}
ref={this.accountInput}
aria-autocomplete="list"
diff --git a/gui/src/renderer/components/LoginStyles.tsx b/gui/src/renderer/components/LoginStyles.tsx
index c8469ba672..3677b60d4e 100644
--- a/gui/src/renderer/components/LoginStyles.tsx
+++ b/gui/src/renderer/components/LoginStyles.tsx
@@ -3,6 +3,7 @@ import { colors } from '../../config.json';
import ImageView from './ImageView';
import * as Cell from './cell';
import { bigText, smallText } from './common-styles';
+import FormattableTextInput from './FormattableTextInput';
export const StyledAccountDropdownContainer = styled.ul({
display: 'flex',
@@ -154,7 +155,7 @@ export const StyledSubtitle = styled.span(smallText, {
marginBottom: '8px',
});
-export const StyledInput = styled.input({
+export const StyledInput = styled(FormattableTextInput)({
minWidth: 0,
borderWidth: 0,
padding: '10px 12px 12px',
diff --git a/gui/src/renderer/components/RedeemVoucher.tsx b/gui/src/renderer/components/RedeemVoucher.tsx
index 29a84f868c..290506309e 100644
--- a/gui/src/renderer/components/RedeemVoucher.tsx
+++ b/gui/src/renderer/components/RedeemVoucher.tsx
@@ -106,9 +106,9 @@ export function RedeemVoucherInput() {
const { value, setValue, onSubmit, submitting, response } = useContext(RedeemVoucherContext);
const disabled = submitting || response?.type === 'success';
- const onChange = useCallback(
- (event: React.ChangeEvent<HTMLInputElement>) => {
- setValue(event.target.value);
+ const handleChange = useCallback(
+ (value: string) => {
+ setValue(value);
},
[setValue],
);
@@ -124,10 +124,16 @@ export function RedeemVoucherInput() {
return (
<StyledInput
+ allowedCharacters="[A-Z0-9]"
+ separator="-"
+ uppercaseOnly
+ groupLength={4}
+ maxLength={16}
+ addTrailingSeparator
disabled={disabled}
value={value}
placeholder={'XXXX-XXXX-XXXX-XXXX'}
- onChange={onChange}
+ handleChange={handleChange}
onKeyPress={onKeyPress}
/>
);
diff --git a/gui/src/renderer/components/RedeemVoucherStyles.tsx b/gui/src/renderer/components/RedeemVoucherStyles.tsx
index cd7d0cc7eb..e710bfe5bf 100644
--- a/gui/src/renderer/components/RedeemVoucherStyles.tsx
+++ b/gui/src/renderer/components/RedeemVoucherStyles.tsx
@@ -1,5 +1,6 @@
import styled from 'styled-components';
import { colors } from '../../config.json';
+import FormattableTextInput from './FormattableTextInput';
import ImageView from './ImageView';
export const StyledLabel = styled.span({
@@ -11,7 +12,7 @@ export const StyledLabel = styled.span({
marginBottom: '9px',
});
-export const StyledInput = styled.input({
+export const StyledInput = styled(FormattableTextInput)({
flex: 1,
overflow: 'hidden',
padding: '14px',