diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2022-09-27 11:37:32 +0200 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2022-09-27 11:37:32 +0200 |
| commit | 8c4d03d4f31069ba9648ffb22c056185d9706167 (patch) | |
| tree | 5d61a310d8f70cbb4ccb5708395cc0e254ff81a9 /gui/src/renderer/components/cell | |
| parent | de9bf32918295037d1616d256afedf52ab7d4b6d (diff) | |
| parent | dcdd4b44efb5f77148d262f4c573dd6aca0a5e03 (diff) | |
| download | mullvadvpn-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.tsx | 183 | ||||
| -rw-r--r-- | gui/src/renderer/components/cell/Selector.tsx | 225 |
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> + ); } |
