summaryrefslogtreecommitdiffhomepage
path: root/gui/src/renderer/components/cell
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2022-09-27 11:37:32 +0200
committerOskar Nyberg <oskar@mullvad.net>2022-09-27 11:37:32 +0200
commit8c4d03d4f31069ba9648ffb22c056185d9706167 (patch)
tree5d61a310d8f70cbb4ccb5708395cc0e254ff81a9 /gui/src/renderer/components/cell
parentde9bf32918295037d1616d256afedf52ab7d4b6d (diff)
parentdcdd4b44efb5f77148d262f4c573dd6aca0a5e03 (diff)
downloadmullvadvpn-8c4d03d4f31069ba9648ffb22c056185d9706167.tar.xz
mullvadvpn-8c4d03d4f31069ba9648ffb22c056185d9706167.zip
Merge branch 'add-custom-port-option'
Diffstat (limited to 'gui/src/renderer/components/cell')
-rw-r--r--gui/src/renderer/components/cell/Input.tsx183
-rw-r--r--gui/src/renderer/components/cell/Selector.tsx225
2 files changed, 272 insertions, 136 deletions
diff --git a/gui/src/renderer/components/cell/Input.tsx b/gui/src/renderer/components/cell/Input.tsx
index 81ba648338..e68fac424c 100644
--- a/gui/src/renderer/components/cell/Input.tsx
+++ b/gui/src/renderer/components/cell/Input.tsx
@@ -2,7 +2,7 @@ import React, { useCallback, useContext, useEffect, useRef, useState } from 'rea
import styled from 'styled-components';
import { colors } from '../../../config.json';
-import { useBoolean } from '../../lib/utilityHooks';
+import { useBoolean, useCombinedRefs } from '../../lib/utilityHooks';
import { normalText } from '../common-styles';
import ImageView from '../ImageView';
import { BackAction } from '../KeyboardNavigation';
@@ -45,112 +45,96 @@ interface IInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
onChangeValue?: (value: string) => void;
}
-interface IInputState {
- value?: string;
- focused: boolean;
-}
-
-export class Input extends React.Component<IInputProps, IInputState> {
- public state = {
- value: this.props.value ?? '',
- focused: false,
- };
-
- public inputRef = React.createRef<HTMLInputElement>();
-
- public componentDidUpdate(prevProps: IInputProps, _prevState: IInputState) {
- if (
- !this.state.focused &&
- prevProps.value !== this.props.value &&
- this.props.value !== this.state.value
- ) {
- this.setState(
- (_state, props) => ({
- value: props.value,
- }),
- () => {
- this.props.onChangeValue?.(this.state.value);
- },
- );
- }
- }
+function InputWithRef(props: IInputProps, forwardedRef: React.Ref<HTMLInputElement>) {
+ const {
+ validateValue,
+ modifyValue,
+ submitOnBlur,
+ onSubmitValue,
+ onChangeValue,
+ ...otherProps
+ } = props;
- public render() {
- const {
- type: _type,
- onChange: _onChange,
- onFocus: _onFocus,
- onBlur: _onBlur,
- onKeyPress: _onKeyPress,
- value: _value,
- modifyValue: _modifyValue,
- submitOnBlur: _submitOnBlur,
- onChangeValue: _onChangeValue,
- onSubmitValue: _onSubmitValue,
- validateValue,
- ...otherProps
- } = this.props;
+ const [value, setValue] = useState(props.value ?? '');
+ const [isFocused, setFocused, setBlurred] = useBoolean(false);
- const valid = validateValue?.(this.state.value);
+ const inputRef = useRef() as React.RefObject<HTMLInputElement>;
+ const combinedRef = useCombinedRefs(inputRef, forwardedRef);
- return (
- <CellDisabledContext.Consumer>
- {(disabled) => (
- <StyledInput
- ref={this.inputRef}
- type="text"
- valid={valid}
- focused={this.state.focused}
- aria-invalid={!valid}
- onChange={this.onChange}
- onFocus={this.onFocus}
- onBlur={this.onBlur}
- onKeyPress={this.onKeyPress}
- value={this.state.value}
- disabled={disabled}
- {...otherProps}
- />
- )}
- </CellDisabledContext.Consumer>
- );
- }
+ const onFocus = useCallback(
+ (event: React.FocusEvent<HTMLInputElement>) => {
+ setFocused();
+ props.onFocus?.(event);
+ },
+ [props.onFocus],
+ );
- public blur = () => {
- this.inputRef.current?.blur();
- };
+ const onBlur = useCallback(
+ (event: React.FocusEvent<HTMLInputElement>) => {
+ setBlurred();
+ props.onBlur?.(event);
- private onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
- const value = this.props.modifyValue?.(event.target.value) ?? event.target.value;
- this.setState({ value });
- this.props.onChange?.(event);
- this.props.onChangeValue?.(value);
- };
+ if (validateValue?.(value) !== false && submitOnBlur) {
+ onSubmitValue?.(value);
+ }
+ },
+ [value, props.onBlur, validateValue, onSubmitValue, submitOnBlur],
+ );
- private onFocus = (event: React.FocusEvent<HTMLInputElement>) => {
- this.setState({ focused: true });
- this.props.onFocus?.(event);
- };
+ const onChange = useCallback(
+ (event: React.ChangeEvent<HTMLInputElement>) => {
+ const value = modifyValue?.(event.target.value) ?? event.target.value;
+ setValue(value);
+ props.onChange?.(event);
+ onChangeValue?.(value);
+ },
+ [value, modifyValue, props.onSubmit, onSubmitValue],
+ );
- private onBlur = (event: React.FocusEvent<HTMLInputElement>) => {
- this.setState({ focused: false });
+ const onKeyPress = useCallback(
+ (event: React.KeyboardEvent<HTMLInputElement>) => {
+ if (event.key === 'Enter') {
+ onSubmitValue?.(value);
+ inputRef.current?.blur();
+ }
+ props.onKeyPress?.(event);
+ },
+ [value, onSubmitValue, inputRef, props.onKeyPress],
+ );
- this.props.onBlur?.(event);
- if (this.props.validateValue?.(this.state.value) !== false && this.props.submitOnBlur) {
- this.props.onSubmitValue?.(this.state.value);
- } else {
- this.setState({ value: this.props.value });
+ useEffect(() => {
+ if (!isFocused && props.value !== undefined && value !== props.value) {
+ setValue(props.value);
+ onChangeValue?.(props.value);
}
- };
+ }, [props.value]);
- private onKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
- if (event.key === 'Enter') {
- this.props.onSubmitValue?.(this.state.value);
- this.inputRef.current?.blur();
- }
- this.props.onKeyPress?.(event);
- };
+ const valid = validateValue?.(value);
+
+ return (
+ <CellDisabledContext.Consumer>
+ {(disabled) => (
+ <StyledInput
+ {...otherProps}
+ ref={combinedRef}
+ type="text"
+ valid={valid}
+ focused={isFocused}
+ aria-invalid={!valid}
+ onChange={onChange}
+ onFocus={onFocus}
+ onBlur={onBlur}
+ onKeyPress={onKeyPress}
+ value={value}
+ disabled={disabled}
+ />
+ )}
+ </CellDisabledContext.Consumer>
+ );
}
+export const Input = React.memo(React.forwardRef(InputWithRef));
+
const InputFrame = styled.div((props: { focused: boolean }) => ({
display: 'flex',
flexGrow: 0,
@@ -177,12 +161,13 @@ const StyledAutoSizingTextInputWrapper = styled.div({
height: '100%',
});
-export function AutoSizingTextInput(props: IInputProps) {
+function AutoSizingTextInputWithRef(props: IInputProps, forwardedRef: React.Ref<HTMLInputElement>) {
const { onChangeValue, onFocus, onBlur, ...otherProps } = props;
const [value, setValue] = useState(otherProps.value ?? '');
const [focused, setFocused, setBlurred] = useBoolean(false);
- const inputRef = useRef() as React.RefObject<Input>;
+ const inputRef = useRef() as React.RefObject<HTMLInputElement>;
+ const combinedRef = useCombinedRefs(inputRef, forwardedRef);
const onChangeValueWrapper = useCallback(
(value: string) => {
@@ -216,7 +201,7 @@ export function AutoSizingTextInput(props: IInputProps) {
<StyledAutoSizingTextInputContainer>
<StyledAutoSizingTextInputWrapper>
<Input
- ref={inputRef}
+ ref={combinedRef}
onChangeValue={onChangeValueWrapper}
onBlur={onBlurWrapper}
onFocus={onFocusWrapper}
@@ -232,6 +217,8 @@ export function AutoSizingTextInput(props: IInputProps) {
);
}
+export const AutoSizingTextInput = React.memo(React.forwardRef(AutoSizingTextInputWithRef));
+
const StyledCellInputRowContainer = styled(Container)({
backgroundColor: 'white',
marginBottom: '1px',
diff --git a/gui/src/renderer/components/cell/Selector.tsx b/gui/src/renderer/components/cell/Selector.tsx
index e815519552..d898e3f51d 100644
--- a/gui/src/renderer/components/cell/Selector.tsx
+++ b/gui/src/renderer/components/cell/Selector.tsx
@@ -1,7 +1,8 @@
-import * as React from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import styled from 'styled-components';
import { colors } from '../../../config.json';
+import { messages } from '../../../shared/gettext';
import { useBoolean } from '../../lib/utilityHooks';
import Accordion from '../Accordion';
import { AriaDetails, AriaInput, AriaLabel } from '../AriaGroup';
@@ -24,44 +25,69 @@ const StyledChevronButton = styled(ChevronButton)({
marginRight: '16px',
});
-export interface ISelectorItem<T> {
+export interface SelectorItem<T> {
label: string;
value: T;
disabled?: boolean;
}
-interface ISelectorProps<T> {
+// T represents the available values and U represent the value of "Automatic"/"Any" if there is one.
+interface CommonSelectorProps<T, U> {
title?: string;
- values: Array<ISelectorItem<T>>;
- value: T;
- onSelect: (value: T) => void;
- selectedCellRef?: React.Ref<HTMLButtonElement>;
+ items: Array<SelectorItem<T>>;
+ value: T | U;
+ selectedCellRef?: React.Ref<HTMLElement>;
className?: string;
details?: React.ReactElement;
expandable?: boolean;
disabled?: boolean;
thinTitle?: boolean;
+ automaticLabel?: string;
+ automaticValue?: U;
+ children?: React.ReactNode | Array<React.ReactNode>;
+}
+
+interface SelectorProps<T, U> extends CommonSelectorProps<T, U> {
+ onSelect: (value: T | U) => void;
}
-export default function Selector<T>(props: ISelectorProps<T>) {
+export default function Selector<T, U>(props: SelectorProps<T, U>) {
const [expanded, , , toggleExpanded] = useBoolean(!props.expandable);
- const items = props.values.map((item, i) => {
- const selected = item.value === props.value;
+ const items = props.items.map((item) => {
+ const selected = props.value === item.value;
+ const ref = selected ? (props.selectedCellRef as React.Ref<HTMLButtonElement>) : undefined;
return (
<SelectorCell
- key={i}
+ key={`value-${item.value}`}
value={item.value}
- selected={selected}
+ isSelected={selected}
disabled={props.disabled || item.disabled}
- forwardedRef={selected ? props.selectedCellRef : undefined}
+ forwardedRef={ref}
onSelect={props.onSelect}>
{item.label}
</SelectorCell>
);
});
+ if (props.automaticValue !== undefined) {
+ const selected = props.value === props.automaticValue;
+ const ref = selected ? (props.selectedCellRef as React.Ref<HTMLButtonElement>) : undefined;
+
+ items.unshift(
+ <SelectorCell
+ key={'automatic'}
+ value={props.automaticValue}
+ isSelected={selected}
+ disabled={props.disabled}
+ forwardedRef={ref}
+ onSelect={props.onSelect}>
+ {props.automaticLabel ?? messages.gettext('Automatic')}
+ </SelectorCell>,
+ );
+ }
+
const title = props.title && (
<StyledTitle>
<AriaLabel>
@@ -78,11 +104,19 @@ export default function Selector<T>(props: ISelectorProps<T>) {
</StyledTitle>
);
+ // Add potential additional items to the list. Used for custom entry.
+ const children = (
+ <>
+ {items}
+ {props.children}
+ </>
+ );
+
return (
<AriaInput>
<Cell.Section role="listbox" className={props.className}>
{title}
- {props.expandable ? <Accordion expanded={expanded}>{items}</Accordion> : items}
+ {props.expandable ? <Accordion expanded={expanded}>{children}</Accordion> : children}
</Cell.Section>
</AriaInput>
);
@@ -97,40 +131,155 @@ const StyledLabel = styled(Cell.Label)(normalText, {
fontWeight: 400,
});
-interface ISelectorCellProps<T> {
+interface SelectorCellProps<T> {
value: T;
- selected: boolean;
+ isSelected: boolean;
disabled?: boolean;
onSelect: (value: T) => void;
- children?: React.ReactText;
+ children: React.ReactNode | Array<React.ReactNode>;
forwardedRef?: React.Ref<HTMLButtonElement>;
}
-class SelectorCell<T> extends React.Component<ISelectorCellProps<T>> {
- public render() {
- return (
- <Cell.CellButton
- ref={this.props.forwardedRef}
- onClick={this.onClick}
- selected={this.props.selected}
- disabled={this.props.disabled}
+function SelectorCell<T>(props: SelectorCellProps<T>) {
+ const handleClick = useCallback(() => {
+ if (!props.isSelected) {
+ props.onSelect(props.value);
+ }
+ }, [props.isSelected, props.onSelect, props.value]);
+
+ return (
+ <Cell.CellButton
+ ref={props.forwardedRef}
+ onClick={handleClick}
+ selected={props.isSelected}
+ disabled={props.disabled}
+ role="option"
+ aria-selected={props.isSelected}
+ aria-disabled={props.disabled}>
+ <StyledCellIcon
+ visible={props.isSelected}
+ source="icon-tick"
+ width={18}
+ tintColor={colors.white}
+ />
+ <StyledLabel>{props.children}</StyledLabel>
+ </Cell.CellButton>
+ );
+}
+
+interface StyledCustomContainerProps {
+ selected: boolean;
+}
+
+const StyledCustomContainer = styled(Cell.Container)((props: StyledCustomContainerProps) => ({
+ minHeight: '44px',
+ backgroundColor: props.selected ? colors.green : colors.blue40,
+ ':hover': {
+ backgroundColor: props.selected ? colors.green : colors.blue,
+ },
+}));
+
+// Adding undefined as possible value of the selector to be able to select nothing.
+interface SelectorWithCustomItemProps<T, U> extends CommonSelectorProps<T | undefined, U> {
+ inputPlaceholder: string;
+ onSelect: (value: T | U) => void;
+ onSelectCustom: (value: string) => void;
+ maxLength?: number;
+ selectedCellRef?: React.Ref<HTMLDivElement>;
+ validateValue?: (value: string) => boolean;
+ modifyValue?: (value: string) => string;
+}
+
+export function SelectorWithCustomItem<T, U>(props: SelectorWithCustomItemProps<T, U>) {
+ const {
+ value,
+ inputPlaceholder,
+ onSelect,
+ onSelectCustom,
+ maxLength,
+ selectedCellRef,
+ validateValue,
+ 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 itemIsSelected =
+ props.items.some((item) => item.value === value) || props.automaticValue === value;
+ const customIsSelected = !itemIsSelected || customIsSelectedWithoutValue;
+
+ const handleClick = useCallback(() => {
+ if (!customIsSelected) {
+ setCustomIsSelectedWithoutValue(true);
+ inputRef.current?.focus();
+ }
+ }, [customIsSelected, inputRef.current]);
+
+ // Wrap onSelect to be able to catch when a new value is selected during the
+ // customIsSelectedWithoutValue phase.
+ const handleSelectValue = useCallback(
+ (newValue: T | U | undefined) => {
+ if (customIsSelectedWithoutValue && newValue === value) {
+ setCustomIsSelectedWithoutValue(false);
+ } else if (newValue !== undefined) {
+ onSelect(newValue);
+ }
+ },
+ [customIsSelected, value, onSelect],
+ );
+
+ const handleSubmit = useCallback((value: string) => {
+ if (validateValue?.(value) !== false) {
+ onSelectCustom(value);
+ }
+ }, []);
+
+ // If props.value changes while customIsSelectedWithoutValue then we want to switch to that value
+ // instead.
+ useEffect(() => {
+ if (customIsSelected) {
+ setCustomIsSelectedWithoutValue(false);
+ }
+ }, [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={this.props.selected}
- aria-disabled={this.props.disabled}>
+ aria-selected={customIsSelected}
+ aria-disabled={props.disabled}>
<StyledCellIcon
- visible={this.props.selected}
+ visible={customIsSelected}
source="icon-tick"
width={18}
tintColor={colors.white}
/>
- <StyledLabel>{this.props.children}</StyledLabel>
- </Cell.CellButton>
- );
- }
-
- private onClick = () => {
- if (!this.props.selected) {
- this.props.onSelect(this.props.value);
- }
- };
+ <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}
+ />
+ </AriaInput>
+ </StyledCustomContainer>
+ </Selector>
+ );
}