summaryrefslogtreecommitdiffhomepage
path: root/gui/src
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2022-10-03 09:52:47 +0200
committerOskar Nyberg <oskar@mullvad.net>2022-10-04 12:46:44 +0200
commitc0c53effbbef8052660829b5e885526566c3b5c5 (patch)
treea9789d6a09b968c5a20bad351c93b41c9f0ad419 /gui/src
parentdb2cec05e44bcd56ed66677229ab425eaf7d3c63 (diff)
downloadmullvadvpn-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.tsx11
-rw-r--r--gui/src/renderer/components/cell/Input.tsx26
-rw-r--r--gui/src/renderer/components/cell/Selector.tsx137
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>
);
}