diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2020-12-10 13:45:58 +0100 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2020-12-10 13:45:58 +0100 |
| commit | 4aa77f22a715758c77888f6817d11068c071d9c1 (patch) | |
| tree | 93edd7cf9ad9fa4c5c96dc9d781cda2472adf00c /gui/src | |
| parent | a832f6f3628272166d7232e839b4fc5091ecdafd (diff) | |
| parent | 6cb7b3d380f72731f431783a656431ef71d9b0ff (diff) | |
| download | mullvadvpn-4aa77f22a715758c77888f6817d11068c071d9c1.tar.xz mullvadvpn-4aa77f22a715758c77888f6817d11068c071d9c1.zip | |
Merge branch 'format-input-value'
Diffstat (limited to 'gui/src')
| -rw-r--r-- | gui/src/renderer/components/FormattableTextInput.tsx | 132 | ||||
| -rw-r--r-- | gui/src/renderer/components/Login.tsx | 8 | ||||
| -rw-r--r-- | gui/src/renderer/components/LoginStyles.tsx | 3 | ||||
| -rw-r--r-- | gui/src/renderer/components/RedeemVoucher.tsx | 14 | ||||
| -rw-r--r-- | gui/src/renderer/components/RedeemVoucherStyles.tsx | 3 |
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', |
