diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2022-10-03 09:52:47 +0200 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2022-10-04 12:46:44 +0200 |
| commit | c0c53effbbef8052660829b5e885526566c3b5c5 (patch) | |
| tree | a9789d6a09b968c5a20bad351c93b41c9f0ad419 /gui/src | |
| parent | db2cec05e44bcd56ed66677229ab425eaf7d3c63 (diff) | |
| download | mullvadvpn-c0c53effbbef8052660829b5e885526566c3b5c5.tar.xz mullvadvpn-c0c53effbbef8052660829b5e885526566c3b5c5.zip | |
Handle edge cases for custom option selector
Diffstat (limited to 'gui/src')
| -rw-r--r-- | gui/src/renderer/components/WireguardSettings.tsx | 11 | ||||
| -rw-r--r-- | gui/src/renderer/components/cell/Input.tsx | 26 | ||||
| -rw-r--r-- | gui/src/renderer/components/cell/Selector.tsx | 137 |
3 files changed, 102 insertions, 72 deletions
diff --git a/gui/src/renderer/components/WireguardSettings.tsx b/gui/src/renderer/components/WireguardSettings.tsx index 609e2847b4..7adcba6140 100644 --- a/gui/src/renderer/components/WireguardSettings.tsx +++ b/gui/src/renderer/components/WireguardSettings.tsx @@ -162,15 +162,10 @@ function PortSelector() { [relaySettings], ); - const setCustomPort = useCallback( - async (port: string) => { - await setWireguardPort(parseInt(port)); - }, - [setWireguardPort], - ); + const parseValue = useCallback((port: string) => parseInt(port), []); const validateValue = useCallback( - (value) => allowedPortRanges.some(([start, end]) => value >= start && value <= end), + (value: number) => allowedPortRanges.some(([start, end]) => value >= start && value <= end), [allowedPortRanges], ); @@ -187,9 +182,9 @@ function PortSelector() { items={wireguardPortItems} value={port} onSelect={setWireguardPort} - onSelectCustom={setCustomPort} inputPlaceholder={messages.pgettext('wireguard-settings-view', 'Port')} automaticValue={null} + parseValue={parseValue} modifyValue={removeNonNumericCharacters} validateValue={validateValue} maxLength={5} diff --git a/gui/src/renderer/components/cell/Input.tsx b/gui/src/renderer/components/cell/Input.tsx index e68fac424c..129d2f8f95 100644 --- a/gui/src/renderer/components/cell/Input.tsx +++ b/gui/src/renderer/components/cell/Input.tsx @@ -42,6 +42,7 @@ interface IInputProps extends React.InputHTMLAttributes<HTMLInputElement> { modifyValue?: (value: string) => string; submitOnBlur?: boolean; onSubmitValue?: (value: string) => void; + onInvalidValue?: (value: string) => void; onChangeValue?: (value: string) => void; } @@ -51,6 +52,7 @@ function InputWithRef(props: IInputProps, forwardedRef: React.Ref<HTMLInputEleme modifyValue, submitOnBlur, onSubmitValue, + onInvalidValue, onChangeValue, ...otherProps } = props; @@ -61,6 +63,17 @@ function InputWithRef(props: IInputProps, forwardedRef: React.Ref<HTMLInputEleme const inputRef = useRef() as React.RefObject<HTMLInputElement>; const combinedRef = useCombinedRefs(inputRef, forwardedRef); + const onSubmit = useCallback( + (value: string) => { + if (validateValue?.(value) !== false && submitOnBlur) { + onSubmitValue?.(value); + } else if (submitOnBlur) { + onInvalidValue?.(value); + } + }, + [onSubmitValue, onInvalidValue], + ); + const onFocus = useCallback( (event: React.FocusEvent<HTMLInputElement>) => { setFocused(); @@ -73,12 +86,9 @@ function InputWithRef(props: IInputProps, forwardedRef: React.Ref<HTMLInputEleme (event: React.FocusEvent<HTMLInputElement>) => { setBlurred(); props.onBlur?.(event); - - if (validateValue?.(value) !== false && submitOnBlur) { - onSubmitValue?.(value); - } + onSubmit(value); }, - [value, props.onBlur, validateValue, onSubmitValue, submitOnBlur], + [value, props.onBlur, validateValue, onSubmit, submitOnBlur], ); const onChange = useCallback( @@ -88,18 +98,18 @@ function InputWithRef(props: IInputProps, forwardedRef: React.Ref<HTMLInputEleme props.onChange?.(event); onChangeValue?.(value); }, - [value, modifyValue, props.onSubmit, onSubmitValue], + [value, modifyValue, props.onSubmit], ); const onKeyPress = useCallback( (event: React.KeyboardEvent<HTMLInputElement>) => { if (event.key === 'Enter') { - onSubmitValue?.(value); + onSubmit(value); inputRef.current?.blur(); } props.onKeyPress?.(event); }, - [value, onSubmitValue, inputRef, props.onKeyPress], + [value, onSubmit, inputRef, props.onKeyPress], ); useEffect(() => { diff --git a/gui/src/renderer/components/cell/Selector.tsx b/gui/src/renderer/components/cell/Selector.tsx index 2c522178d6..11a2bc5e28 100644 --- a/gui/src/renderer/components/cell/Selector.tsx +++ b/gui/src/renderer/components/cell/Selector.tsx @@ -182,10 +182,10 @@ const StyledCustomContainer = styled(Cell.Container)((props: StyledCustomContain interface SelectorWithCustomItemProps<T, U> extends CommonSelectorProps<T | undefined, U> { inputPlaceholder: string; onSelect: (value: T | U) => void; - onSelectCustom: (value: string) => void; + parseValue: (value: string) => T; + validateValue?: (value: T) => boolean; maxLength?: number; selectedCellRef?: React.Ref<HTMLDivElement>; - validateValue?: (value: string) => boolean; modifyValue?: (value: string) => string; } @@ -194,91 +194,116 @@ export function SelectorWithCustomItem<T, U>(props: SelectorWithCustomItemProps< value, inputPlaceholder, onSelect, - onSelectCustom, maxLength, selectedCellRef, validateValue, + parseValue, modifyValue, ...otherProps } = props; // The component needs to keep track of when the custom item should look selected before it has a // value. - const [customIsSelectedWithoutValue, setCustomIsSelectedWithoutValue] = useState(false); - const inputRef = useRef() as React.RefObject<HTMLInputElement>; + const [customWithoutValue, setCustomWithoutValue, unsetCustomWithoutValue] = useBoolean(false); - const itemIsSelected = + const isNonCustomItem = (value: T | U | undefined) => props.items.some((item) => item.value === value) || props.automaticValue === value; - const customIsSelected = !itemIsSelected || customIsSelectedWithoutValue; + + const itemIsSelected = isNonCustomItem(value); + const customIsSelected = !itemIsSelected || customWithoutValue; + + // The input key is used to clear the input state. + const [inputKey, setInputKey] = useState(1); + const resetInput = () => setInputKey((key) => key + 1); + const inputRef = useRef() as React.RefObject<HTMLInputElement>; const handleClick = useCallback(() => { + inputRef.current?.focus(); if (!customIsSelected) { - setCustomIsSelectedWithoutValue(true); - inputRef.current?.focus(); + setCustomWithoutValue(); } }, [customIsSelected, inputRef.current]); + const handleMouseDown = useCallback((event: React.MouseEvent) => event.preventDefault(), []); + // Wrap onSelect to be able to catch when a new value is selected during the - // customIsSelectedWithoutValue phase. + // customIsSelectedWithoutValue phase. Value wont be undefined here since undefined items aren't + // allowed. const handleSelectValue = useCallback( (newValue: T | U | undefined) => { - if (customIsSelectedWithoutValue && newValue === value) { - setCustomIsSelectedWithoutValue(false); - } else if (newValue !== undefined) { - onSelect(newValue); + resetInput(); + onSelect(newValue!); + }, + [value, onSelect], + ); + + const validateStringValue = useCallback( + (value: string) => validateValue?.(parseValue(value)) ?? true, + [parseValue, validateValue], + ); + + const handleSubmit = useCallback( + (stringValue: string) => { + const value = parseValue(stringValue); + + if (isNonCustomItem(value)) { + resetInput(); } + + onSelect(value); }, - [customIsSelected, value, onSelect], + [parseValue, onSelect], ); - const handleSubmit = useCallback((value: string) => { - if (validateValue?.(value) !== false) { - onSelectCustom(value); - } + const handleInvalid = useCallback(() => { + resetInput(); + unsetCustomWithoutValue(); }, []); - // If props.value changes while customIsSelectedWithoutValue then we want to switch to that value - // instead. useEffect(() => { - if (customIsSelected) { - setCustomIsSelectedWithoutValue(false); + if (customWithoutValue && itemIsSelected) { + unsetCustomWithoutValue(); } }, [value]); return ( - <Selector<T | undefined, U> - {...otherProps} - onSelect={handleSelectValue} - value={customIsSelected ? undefined : value}> - <StyledCustomContainer - ref={customIsSelected ? props.selectedCellRef : undefined} - onClick={handleClick} - selected={customIsSelected} - disabled={props.disabled} - role="option" - aria-selected={customIsSelected} - aria-disabled={props.disabled}> - <StyledCellIcon - visible={customIsSelected} - source="icon-tick" - width={18} - tintColor={colors.white} - /> - <StyledLabel>{messages.gettext('Custom')}</StyledLabel> - <AriaInput> - <Cell.AutoSizingTextInput - ref={inputRef} - value={itemIsSelected || customIsSelectedWithoutValue ? '' : `${props.value}`} - placeholder={inputPlaceholder} - inputMode={'numeric'} - maxLength={maxLength ?? 4} - onSubmitValue={handleSubmit} - submitOnBlur={true} - validateValue={validateValue} - modifyValue={modifyValue} + <div onMouseDown={handleMouseDown}> + <Selector<T | undefined, U> + {...otherProps} + onSelect={handleSelectValue} + value={customIsSelected ? undefined : value}> + <StyledCustomContainer + ref={customIsSelected ? props.selectedCellRef : undefined} + onClick={handleClick} + selected={customIsSelected} + disabled={props.disabled} + role="option" + aria-selected={customIsSelected} + aria-disabled={props.disabled}> + <StyledCellIcon + visible={customIsSelected} + source="icon-tick" + width={18} + tintColor={colors.white} /> - </AriaInput> - </StyledCustomContainer> - </Selector> + <StyledLabel>{messages.gettext('Custom')}</StyledLabel> + <AriaInput> + <Cell.AutoSizingTextInput + key={inputKey} + ref={inputRef} + value={itemIsSelected || customWithoutValue ? '' : `${props.value}`} + placeholder={inputPlaceholder} + inputMode={'numeric'} + maxLength={maxLength ?? 4} + onSubmitValue={handleSubmit} + onInvalidValue={handleInvalid} + submitOnBlur={true} + validateValue={validateStringValue} + modifyValue={modifyValue} + /> + </AriaInput> + </StyledCustomContainer> + </Selector> + </div> ); } |
