summaryrefslogtreecommitdiffhomepage
path: root/gui/src/renderer/components
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2020-04-28 15:01:42 +0200
committerOskar Nyberg <oskar@mullvad.net>2020-04-28 15:01:42 +0200
commite90dcd38409bb69c0b649142f85a8bddaaccef37 (patch)
tree6da5cafc0c67dfc4d95f4a61f2d7bfc7afa457f9 /gui/src/renderer/components
parentf732c379f77bc7379787b1e0c780118e7870b98a (diff)
parent2243c98b5c870037dd47bf16573fc27f22c98a39 (diff)
downloadmullvadvpn-e90dcd38409bb69c0b649142f85a8bddaaccef37.tar.xz
mullvadvpn-e90dcd38409bb69c0b649142f85a8bddaaccef37.zip
Merge branch 'styled-components-experimentation'
Diffstat (limited to 'gui/src/renderer/components')
-rw-r--r--gui/src/renderer/components/Accordion.tsx160
-rw-r--r--gui/src/renderer/components/Account.tsx129
-rw-r--r--gui/src/renderer/components/AccountStyles.tsx110
-rw-r--r--gui/src/renderer/components/AccountTokenLabel.tsx5
-rw-r--r--gui/src/renderer/components/ClipboardLabel.tsx37
-rw-r--r--gui/src/renderer/components/ExpiredAccountErrorView.tsx8
-rw-r--r--gui/src/renderer/components/ExpiredAccountErrorViewStyles.tsx17
-rw-r--r--gui/src/renderer/components/Layout.tsx31
-rw-r--r--gui/src/renderer/components/LayoutStyles.tsx10
-rw-r--r--gui/src/renderer/components/Switch.tsx242
10 files changed, 300 insertions, 449 deletions
diff --git a/gui/src/renderer/components/Accordion.tsx b/gui/src/renderer/components/Accordion.tsx
index cf6603774e..ca37235845 100644
--- a/gui/src/renderer/components/Accordion.tsx
+++ b/gui/src/renderer/components/Accordion.tsx
@@ -1,137 +1,93 @@
import * as React from 'react';
-import { Animated, Component, Styles, Types, UserInterface, View } from 'reactxp';
-import consumePromise from '../../shared/promise';
+import styled from 'styled-components';
interface IProps {
expanded: boolean;
animationDuration: number;
- style?: Types.AnimatedViewStyleRuleSet;
children?: React.ReactNode;
}
interface IState {
- applyAnimatedStyle: boolean;
mountChildren: boolean;
+ containerHeight: string;
}
-const containerOverflowStyle = Styles.createViewStyle({ overflow: 'hidden' });
+const Container = styled.div((props: { height: string; animationDuration: number }) => ({
+ display: 'flex',
+ height: props.height,
+ overflow: 'hidden',
+ transition: `height ${props.animationDuration}ms ease-in-out`,
+}));
+
+const Content = styled.div({
+ display: 'flex',
+ flexDirection: 'column',
+ flex: 1,
+ height: 'fit-content',
+});
+
+export default class Accordion extends React.Component<IProps, IState> {
+ private containerRef = React.createRef<HTMLDivElement>();
-export default class Accordion extends Component<IProps, IState> {
public static defaultProps = {
expanded: true,
animationDuration: 350,
};
public state: IState = {
- applyAnimatedStyle: false,
- mountChildren: false,
+ mountChildren: this.props.expanded,
+ containerHeight: this.props.expanded ? 'auto' : '0',
};
- private heightValue = Animated.createValue(0);
- private animatedStyle = Styles.createAnimatedViewStyle({
- height: this.heightValue,
- });
-
- private containerRef = React.createRef<Animated.View>();
- private contentRef = React.createRef<View>();
- private animation?: Types.Animated.CompositeAnimation = undefined;
-
- constructor(props: IProps) {
- super(props);
-
- this.state = {
- applyAnimatedStyle: !props.expanded,
- mountChildren: props.expanded,
- };
- }
-
- public componentWillUnmount() {
- if (this.animation) {
- this.animation.stop();
- }
- }
-
- public componentDidUpdate(oldProps: IProps, oldState: IState) {
- if (this.props.expanded !== oldProps.expanded) {
- // make sure the children are mounted first before expanding the accordion
- if (this.props.expanded && !this.state.mountChildren) {
- this.setState({ mountChildren: true });
- } else {
- consumePromise(this.animate(this.props.expanded));
- }
- } else if (this.state.mountChildren && !oldState.mountChildren) {
- // run animations once the children are mounted
- consumePromise(this.animate(this.props.expanded));
+ public componentDidUpdate(oldProps: IProps) {
+ if (this.props.expanded && !oldProps.expanded) {
+ this.expand();
+ } else if (!this.props.expanded && oldProps.expanded) {
+ this.collapse();
}
}
public render() {
- const { style, children, expanded, animationDuration, ...otherProps } = this.props;
- const containerStyles = this.state.applyAnimatedStyle
- ? [style, containerOverflowStyle, this.animatedStyle]
- : [style];
-
return (
- <Animated.View {...otherProps} style={containerStyles} ref={this.containerRef}>
- <View ref={this.contentRef}>{this.state.mountChildren && children}</View>
- </Animated.View>
+ <Container
+ ref={this.containerRef}
+ height={this.state.containerHeight}
+ animationDuration={this.props.animationDuration}
+ onTransitionEnd={this.onTransitionEnd}>
+ <Content>{this.state.mountChildren && this.props.children}</Content>
+ </Container>
);
}
- private async animate(expand: boolean) {
- const containerView = this.containerRef.current;
- const contentView = this.contentRef.current;
- if (!containerView || !contentView) {
- return;
- }
-
- if (this.animation) {
- this.animation.stop();
- this.animation = undefined;
- }
-
- const containerLayout = await UserInterface.measureLayoutRelativeToWindow(containerView);
- const contentLayout = await UserInterface.measureLayoutRelativeToAncestor(
- contentView,
- containerView,
- );
-
- // the content is expanded when the animated style is not applied,
- // so reset the initial animated value to the current layout's height.
- if (!this.state.applyAnimatedStyle) {
- this.heightValue.setValue(containerLayout.height);
+ private expand() {
+ // Make sure the children are mounted first before expanding the accordion
+ if (!this.state.mountChildren) {
+ this.setState({ mountChildren: true }, () => {
+ this.setState({ containerHeight: this.getContentHeight() });
+ });
+ } else {
+ this.setState({ containerHeight: this.getContentHeight() });
}
+ }
- const toValue = expand ? contentLayout.height : 0;
-
- // calculate the animation duration based on travel distance
- const multiplier =
- Math.abs(toValue - containerLayout.height) / Math.max(1, contentLayout.height);
- const duration = Math.ceil(this.props.animationDuration * multiplier);
-
- const animation = Animated.timing(this.heightValue, {
- toValue,
- easing: Animated.Easing.InOut(),
- duration,
- useNativeDriver: true,
+ private collapse() {
+ // First change height to height in px since it's not possible to transition to/from auto
+ this.setState({ containerHeight: this.getContentHeight() }, () => {
+ // Make sure new height has been applied
+ // eslint-disable-next-line @typescript-eslint/no-unused-expressions
+ this.containerRef.current?.offsetHeight;
+ this.setState({ containerHeight: '0' });
});
+ }
- this.animation = animation;
-
- const onAnimationEnd = ({ finished }: Types.Animated.EndResult) => {
- if (finished) {
- this.animation = undefined;
-
- // reset the height after transition to let element layout naturally
- // if animation finished without interruption
- if (expand) {
- this.setState({ applyAnimatedStyle: false });
- }
- }
- };
-
- this.setState({ applyAnimatedStyle: true }, () => {
- animation.start(onAnimationEnd);
- });
+ private getContentHeight(): string {
+ return (this.containerRef.current?.scrollHeight ?? 0) + 'px';
}
+
+ private onTransitionEnd = () => {
+ if (this.props.expanded) {
+ // Height auto enables the container to grow if the content changes size
+ this.setState({ containerHeight: 'auto' });
+ }
+ };
}
diff --git a/gui/src/renderer/components/Account.tsx b/gui/src/renderer/components/Account.tsx
index f0cbbe0495..73cf77dd5a 100644
--- a/gui/src/renderer/components/Account.tsx
+++ b/gui/src/renderer/components/Account.tsx
@@ -1,11 +1,18 @@
import * as React from 'react';
-import { Component, Text, View } from 'reactxp';
import AccountExpiry from '../../shared/account-expiry';
import { messages } from '../../shared/gettext';
-import styles from './AccountStyles';
+import styles, {
+ AccountContainer,
+ AccountFooter,
+ AccountOutOfTime,
+ AccountRow,
+ AccountRowLabel,
+ AccountRowValue,
+ StyledContainer,
+} from './AccountStyles';
import AccountTokenLabel from './AccountTokenLabel';
import * as AppButton from './AppButton';
-import { Container, Layout } from './Layout';
+import { Layout } from './Layout';
import { BackBarItem, NavigationBar, NavigationItems } from './NavigationBar';
import SettingsHeader, { HeaderTitle } from './SettingsHeader';
@@ -21,70 +28,62 @@ interface IProps {
onBuyMore: () => Promise<void>;
}
-export default class Account extends Component<IProps> {
+export default class Account extends React.Component<IProps> {
public render() {
return (
<Layout>
- <Container>
- <View style={styles.account}>
- <NavigationBar>
- <NavigationItems>
- <BackBarItem action={this.props.onClose}>
- {
- // TRANSLATORS: Back button in navigation bar
- messages.pgettext('navigation-bar', 'Settings')
- }
- </BackBarItem>
- </NavigationItems>
- </NavigationBar>
+ <StyledContainer>
+ <NavigationBar>
+ <NavigationItems>
+ <BackBarItem action={this.props.onClose}>
+ {
+ // TRANSLATORS: Back button in navigation bar
+ messages.pgettext('navigation-bar', 'Settings')
+ }
+ </BackBarItem>
+ </NavigationItems>
+ </NavigationBar>
- <View style={styles.account__container}>
- <SettingsHeader>
- <HeaderTitle>{messages.pgettext('account-view', 'Account')}</HeaderTitle>
- </SettingsHeader>
+ <AccountContainer>
+ <SettingsHeader>
+ <HeaderTitle>{messages.pgettext('account-view', 'Account')}</HeaderTitle>
+ </SettingsHeader>
- <View style={styles.account__content}>
- <View style={styles.account__main}>
- <View style={styles.account__row}>
- <Text style={styles.account__row_label}>
- {messages.pgettext('account-view', 'Account number')}
- </Text>
- <AccountTokenLabel
- style={styles.account__row_value}
- accountToken={this.props.accountToken || ''}
- />
- </View>
+ <AccountRow>
+ <AccountRowLabel>
+ {messages.pgettext('account-view', 'Account number')}
+ </AccountRowLabel>
+ <AccountRowValue
+ as={AccountTokenLabel}
+ accountToken={this.props.accountToken || ''}
+ />
+ </AccountRow>
- <View style={styles.account__row}>
- <Text style={styles.account__row_label}>
- {messages.pgettext('account-view', 'Paid until')}
- </Text>
- <FormattedAccountExpiry
- expiry={this.props.accountExpiry}
- locale={this.props.expiryLocale}
- />
- </View>
+ <AccountRow>
+ <AccountRowLabel>{messages.pgettext('account-view', 'Paid until')}</AccountRowLabel>
+ <FormattedAccountExpiry
+ expiry={this.props.accountExpiry}
+ locale={this.props.expiryLocale}
+ />
+ </AccountRow>
- <View style={styles.account__footer}>
- <AppButton.BlockingButton
- disabled={this.props.isOffline}
- onPress={this.props.onBuyMore}>
- <AppButton.GreenButton style={styles.account__buy_button}>
- <AppButton.Label>
- {messages.pgettext('account-view', 'Buy more credit')}
- </AppButton.Label>
- <AppButton.Icon source="icon-extLink" height={16} width={16} />
- </AppButton.GreenButton>
- </AppButton.BlockingButton>
- <AppButton.RedButton onPress={this.props.onLogout}>
- {messages.pgettext('account-view', 'Log out')}
- </AppButton.RedButton>
- </View>
- </View>
- </View>
- </View>
- </View>
- </Container>
+ <AccountFooter>
+ <AppButton.BlockingButton
+ disabled={this.props.isOffline}
+ onPress={this.props.onBuyMore}>
+ <AppButton.GreenButton style={styles.account__buy_button}>
+ <AppButton.Label>
+ {messages.pgettext('account-view', 'Buy more credit')}
+ </AppButton.Label>
+ <AppButton.Icon source="icon-extLink" height={16} width={16} />
+ </AppButton.GreenButton>
+ </AppButton.BlockingButton>
+ <AppButton.RedButton onPress={this.props.onLogout}>
+ {messages.pgettext('account-view', 'Log out')}
+ </AppButton.RedButton>
+ </AccountFooter>
+ </AccountContainer>
+ </StyledContainer>
</Layout>
);
}
@@ -96,18 +95,16 @@ function FormattedAccountExpiry(props: { expiry?: string; locale: string }) {
if (expiry.hasExpired()) {
return (
- <Text style={styles.account__out_of_time}>
- {messages.pgettext('account-view', 'OUT OF TIME')}
- </Text>
+ <AccountOutOfTime>{messages.pgettext('account-view', 'OUT OF TIME')}</AccountOutOfTime>
);
} else {
- return <Text style={styles.account__row_value}>{expiry.formattedDate()}</Text>;
+ return <AccountRowValue>{expiry.formattedDate()}</AccountRowValue>;
}
} else {
return (
- <Text style={styles.account__row_value}>
+ <AccountRowValue>
{messages.pgettext('account-view', 'Currently unavailable')}
- </Text>
+ </AccountRowValue>
);
}
}
diff --git a/gui/src/renderer/components/AccountStyles.tsx b/gui/src/renderer/components/AccountStyles.tsx
index bb230ee759..c793b96a1b 100644
--- a/gui/src/renderer/components/AccountStyles.tsx
+++ b/gui/src/renderer/components/AccountStyles.tsx
@@ -1,68 +1,58 @@
import { Styles } from 'reactxp';
+import styled from 'styled-components';
import { colors } from '../../config.json';
+import { Container } from './Layout';
+
+export const StyledContainer = styled(Container)({
+ backgroundColor: colors.darkBlue,
+ flexDirection: 'column',
+});
+
+export const AccountContainer = styled.div({
+ display: 'flex',
+ flexDirection: 'column',
+ flex: 1,
+ paddingBottom: '48px',
+});
+
+export const AccountRow = styled.div({
+ padding: '0 24px',
+ marginBottom: '24px',
+});
+
+const AccountRowText = styled.span({
+ display: 'block',
+ fontFamily: 'Open Sans',
+});
+
+export const AccountRowLabel = styled(AccountRowText)({
+ fontSize: '13px',
+ fontWeight: 600,
+ lineHeight: '20px',
+ letterSpacing: -0.2,
+ marginBottom: '9px',
+ color: colors.white60,
+});
+
+export const AccountRowValue = styled(AccountRowText)({
+ fontSize: '16px',
+ lineHeight: '19px',
+ fontWeight: 800,
+ color: colors.white,
+});
+
+export const AccountOutOfTime = styled(AccountRowValue)({
+ color: colors.red,
+});
+
+export const AccountFooter = styled.div({
+ display: 'flex',
+ flexDirection: 'column',
+ padding: '0 24px',
+});
export default {
- account: Styles.createViewStyle({
- backgroundColor: colors.darkBlue,
- flex: 1,
- }),
- account__container: Styles.createViewStyle({
- flexDirection: 'column',
- flex: 1,
- paddingBottom: 48,
- }),
- account__scrollview: Styles.createViewStyle({
- flex: 1,
- }),
- account__content: Styles.createViewStyle({
- flexDirection: 'column',
- flex: 1,
- }),
- account__main: Styles.createViewStyle({
- marginBottom: 24,
- }),
- account__row: Styles.createViewStyle({
- paddingTop: 0,
- paddingBottom: 0,
- paddingLeft: 24,
- paddingRight: 24,
- marginBottom: 24,
- }),
- account__footer: Styles.createViewStyle({
- paddingLeft: 24,
- paddingRight: 24,
- }),
account__buy_button: Styles.createViewStyle({
marginBottom: 24,
}),
- account__row_label: Styles.createTextStyle({
- fontFamily: 'Open Sans',
- fontSize: 13,
- fontWeight: '600',
- lineHeight: 20,
- letterSpacing: -0.2,
- color: colors.white60,
- marginBottom: 9,
- }),
- account__row_value: Styles.createTextStyle({
- fontFamily: 'Open Sans',
- fontSize: 16,
- lineHeight: 19,
- fontWeight: '800',
- color: colors.white,
- }),
- account__out_of_time: Styles.createTextStyle({
- fontFamily: 'Open Sans',
- fontSize: 16,
- fontWeight: '800',
- color: colors.red,
- }),
- account__footer_label: Styles.createTextStyle({
- fontFamily: 'Open Sans',
- fontSize: 13,
- fontWeight: '600',
- lineHeight: 20,
- letterSpacing: -0.2,
- color: colors.white80,
- }),
};
diff --git a/gui/src/renderer/components/AccountTokenLabel.tsx b/gui/src/renderer/components/AccountTokenLabel.tsx
index d581749871..a2459ee61a 100644
--- a/gui/src/renderer/components/AccountTokenLabel.tsx
+++ b/gui/src/renderer/components/AccountTokenLabel.tsx
@@ -1,19 +1,18 @@
import * as React from 'react';
-import { Types } from 'reactxp';
import { formatAccountToken } from '../lib/account';
import ClipboardLabel from './ClipboardLabel';
interface IAccountTokenLabelProps {
accountToken: string;
- style?: Types.StyleRuleSetRecursive<Types.TextStyleRuleSet>;
+ className?: string;
}
export default function AccountTokenLabel(props: IAccountTokenLabelProps) {
return (
<ClipboardLabel
- style={props.style}
value={props.accountToken}
displayValue={formatAccountToken(props.accountToken)}
+ className={props.className}
/>
);
}
diff --git a/gui/src/renderer/components/ClipboardLabel.tsx b/gui/src/renderer/components/ClipboardLabel.tsx
index 567c41e45c..e0a507cc48 100644
--- a/gui/src/renderer/components/ClipboardLabel.tsx
+++ b/gui/src/renderer/components/ClipboardLabel.tsx
@@ -1,20 +1,26 @@
+import log from 'electron-log';
import * as React from 'react';
-import { Clipboard, Component, Text, Types } from 'reactxp';
+import styled from 'styled-components';
import { messages } from '../../shared/gettext';
+import { Scheduler } from '../../shared/scheduler';
interface IProps {
value: string;
displayValue?: string;
delay: number;
message: string;
- style?: Types.StyleRuleSetRecursive<Types.TextStyleRuleSet>;
+ className?: string;
}
interface IState {
showsMessage: boolean;
}
-export default class ClipboardLabel extends Component<IProps, IState> {
+const Label = styled.span({
+ cursor: 'pointer',
+});
+
+export default class ClipboardLabel extends React.Component<IProps, IState> {
public static defaultProps: Partial<IProps> = {
delay: 3000,
message: messages.gettext('COPIED TO CLIPBOARD!'),
@@ -24,31 +30,28 @@ export default class ClipboardLabel extends Component<IProps, IState> {
showsMessage: false,
};
- private timer?: NodeJS.Timeout;
+ private scheduler = new Scheduler();
public componentWillUnmount() {
- if (this.timer) {
- clearTimeout(this.timer);
- }
+ this.scheduler.cancel();
}
public render() {
const displayValue = this.props.displayValue || this.props.value;
return (
- <Text style={this.props.style} onPress={this.handlePress}>
+ <Label className={this.props.className} onClick={this.handlePress}>
{this.state.showsMessage ? this.props.message : displayValue}
- </Text>
+ </Label>
);
}
- private handlePress = () => {
- if (this.timer) {
- clearTimeout(this.timer);
+ private handlePress = async () => {
+ try {
+ await navigator.clipboard.writeText(this.props.value);
+ this.scheduler.schedule(() => this.setState({ showsMessage: false }), this.props.delay);
+ this.setState({ showsMessage: true });
+ } catch (error) {
+ log.error(`Failed to copy to clipboard: ${error.message}`);
}
-
- this.timer = global.setTimeout(() => this.setState({ showsMessage: false }), this.props.delay);
- this.setState({ showsMessage: true });
-
- Clipboard.setText(this.props.value);
};
}
diff --git a/gui/src/renderer/components/ExpiredAccountErrorView.tsx b/gui/src/renderer/components/ExpiredAccountErrorView.tsx
index 68f0c9c30b..b2ae12f9b5 100644
--- a/gui/src/renderer/components/ExpiredAccountErrorView.tsx
+++ b/gui/src/renderer/components/ExpiredAccountErrorView.tsx
@@ -7,10 +7,9 @@ import { AccountToken } from '../../shared/daemon-rpc-types';
import { messages } from '../../shared/gettext';
import RedeemVoucherContainer from '../containers/RedeemVoucherContainer';
import { LoginState } from '../redux/account/reducers';
-import AccountTokenLabel from './AccountTokenLabel';
import * as AppButton from './AppButton';
import * as Cell from './Cell';
-import styles from './ExpiredAccountErrorViewStyles';
+import styles, { StyledAccountTokenLabel } from './ExpiredAccountErrorViewStyles';
import ImageView from './ImageView';
import { ModalAlert, ModalAlertType } from './Modal';
import {
@@ -121,10 +120,7 @@ export default class ExpiredAccountErrorView extends Component<
{messages.pgettext('connect-view', 'Here’s your account number. Save it!')}
</Text>
<View style={styles.accountTokenContainer}>
- <AccountTokenLabel
- style={styles.accountToken}
- accountToken={this.props.accountToken || ''}
- />
+ <StyledAccountTokenLabel accountToken={this.props.accountToken || ''} />
</View>
</View>
diff --git a/gui/src/renderer/components/ExpiredAccountErrorViewStyles.tsx b/gui/src/renderer/components/ExpiredAccountErrorViewStyles.tsx
index 93535a043b..15891dd230 100644
--- a/gui/src/renderer/components/ExpiredAccountErrorViewStyles.tsx
+++ b/gui/src/renderer/components/ExpiredAccountErrorViewStyles.tsx
@@ -1,5 +1,15 @@
import { Styles } from 'reactxp';
+import styled from 'styled-components';
import { colors } from '../../config.json';
+import AccountTokenLabel from './AccountTokenLabel';
+
+export const StyledAccountTokenLabel = styled(AccountTokenLabel)({
+ fontFamily: 'Open Sans',
+ lineHeight: '24px',
+ fontSize: '24px',
+ fontWeight: 800,
+ color: colors.white,
+});
export default {
container: Styles.createViewStyle({
@@ -56,13 +66,6 @@ export default {
height: 68,
justifyContent: 'center',
}),
- accountToken: Styles.createTextStyle({
- fontFamily: 'Open Sans',
- lineHeight: 24,
- fontSize: 24,
- fontWeight: '800',
- color: colors.white,
- }),
button: Styles.createViewStyle({
marginBottom: 24,
}),
diff --git a/gui/src/renderer/components/Layout.tsx b/gui/src/renderer/components/Layout.tsx
index 01f4aa701d..825cbf9190 100644
--- a/gui/src/renderer/components/Layout.tsx
+++ b/gui/src/renderer/components/Layout.tsx
@@ -1,5 +1,7 @@
import * as React from 'react';
import { Component, View } from 'reactxp';
+import styled from 'styled-components';
+import { colors } from '../../config.json';
import HeaderBar from './HeaderBar';
import styles from './LayoutStyles';
@@ -15,20 +17,17 @@ export class Header extends Component<HeaderBar['props']> {
}
}
-interface IContainerProps {
- children: React.ReactNode;
-}
-export class Container extends Component<IContainerProps> {
- public render() {
- return <View style={styles.container}>{this.props.children}</View>;
- }
-}
+export const Container = styled.div({
+ display: 'flex',
+ flexDirection: 'column',
+ flex: 1,
+ backgroundColor: colors.blue,
+ overflow: 'hidden',
+});
-interface ILayoutProps {
- children: React.ReactNode;
-}
-export class Layout extends Component<ILayoutProps> {
- public render() {
- return <View style={styles.layout}>{this.props.children}</View>;
- }
-}
+export const Layout = styled.div({
+ display: 'flex',
+ flexDirection: 'column',
+ flex: 1,
+ height: '100vh',
+});
diff --git a/gui/src/renderer/components/LayoutStyles.tsx b/gui/src/renderer/components/LayoutStyles.tsx
index cebe3f2588..aa27549f39 100644
--- a/gui/src/renderer/components/LayoutStyles.tsx
+++ b/gui/src/renderer/components/LayoutStyles.tsx
@@ -1,17 +1,7 @@
import { Styles } from 'reactxp';
-import { colors } from '../../config.json';
export default {
- layout: Styles.createViewStyle({
- flexDirection: 'column',
- flex: 1,
- }),
header: Styles.createViewStyle({
flex: 0,
}),
- container: Styles.createViewStyle({
- flex: 1,
- backgroundColor: colors.blue,
- overflow: 'hidden',
- }),
};
diff --git a/gui/src/renderer/components/Switch.tsx b/gui/src/renderer/components/Switch.tsx
index 3e693706f9..ceda826fb0 100644
--- a/gui/src/renderer/components/Switch.tsx
+++ b/gui/src/renderer/components/Switch.tsx
@@ -1,5 +1,5 @@
-import * as React from 'react';
-import { Animated, Component, GestureView, Styles, Types, View } from 'reactxp';
+import React from 'react';
+import styled from 'styled-components';
import { colors } from '../../config.json';
interface IProps {
@@ -12,80 +12,43 @@ interface IState {
isPressed: boolean;
}
-const styles = {
- holder: Styles.createViewStyle({
- width: 52,
- height: 32,
- borderColor: colors.white,
- borderWidth: 2,
- borderStyle: 'solid',
- borderRadius: 16,
- padding: 2,
- }),
- knob: Styles.createViewStyle({
- height: 24,
- borderRadius: 24,
- }),
-};
+const PAN_DISTANCE = 10;
-interface IPosition {
- x: number;
- y: number;
-}
-
-const SWITCH_DEFAULT_WIDTH = 24;
-const SWITCH_PRESSED_WIDTH = 28;
+const SwitchContainer = styled.div({
+ position: 'relative',
+ width: '52px',
+ height: '32px',
+ borderColor: colors.white,
+ borderWidth: '2px',
+ borderStyle: 'solid',
+ borderRadius: '16px',
+ padding: '2px',
+});
-export default class Switch extends Component<IProps, IState> {
- public static defaultProps: Partial<IProps> = {
- isOn: false,
- onChange: undefined,
- };
+const Knob = styled.div({}, (props: { isOn: boolean; isPressed: boolean }) => ({
+ position: 'absolute',
+ height: '24px',
+ borderRadius: '12px',
+ transition: 'all 200ms linear',
+ width: props.isPressed ? '28px' : '24px',
+ backgroundColor: props.isOn ? colors.green : colors.red,
+ // When enabled the button should be placed all the way to the right (100%) minus padding (2px).
+ left: props.isOn ? 'calc(100% - 2px)' : '2px',
+ // This moves the knob to the left making the right side aligned with the parent's right side.
+ transform: `translateX(${props.isOn ? '-100%' : '0'})`,
+}));
+export default class Switch extends React.Component<IProps, IState> {
public state: IState = {
- isOn: false,
+ isOn: this.props.isOn,
isPressed: false,
};
- private isPanning = false;
- private startPos = { x: 0, y: 0 };
- private startValue = false;
-
- private translationValue = Animated.createValue(0);
- private widthValue = Animated.createValue(SWITCH_DEFAULT_WIDTH);
- private colorValue = Animated.createValue(0);
- private interpolatedColorValue = Animated.interpolate(
- this.colorValue,
- [0, 1],
- [colors.red, colors.green],
- );
- private animatedStyle = Styles.createAnimatedViewStyle({
- width: this.widthValue,
- backgroundColor: this.interpolatedColorValue,
- transform: [
- {
- translateX: this.translationValue,
- },
- ],
- });
- private animation?: Types.Animated.CompositeAnimation;
-
- constructor(props: IProps) {
- super(props);
+ private containerRef = React.createRef<HTMLDivElement>();
- this.state.isOn = props.isOn;
-
- if (props.isOn) {
- this.translationValue.setValue(this.computeTranslation(props.isOn, false));
- this.colorValue.setValue(1);
- }
- }
-
- public componentWillUnmount() {
- if (this.animation) {
- this.animation.stop();
- }
- }
+ private isPanning = false;
+ private startPos = 0;
+ private changedDuringPan = false;
public shouldComponentUpdate(nextProps: IProps, nextState: IState) {
return (
@@ -95,131 +58,86 @@ export default class Switch extends Component<IProps, IState> {
);
}
- public componentDidUpdate(prevProps: IProps, prevState: IState) {
+ public componentDidUpdate(prevProps: IProps, _prevState: IState) {
if (
this.props.isOn !== prevProps.isOn &&
this.props.isOn !== this.state.isOn &&
!this.isPanning
) {
this.setState({ isOn: this.props.isOn });
- } else if (prevState.isOn !== this.state.isOn || prevState.isPressed !== this.state.isPressed) {
- this.animate();
}
}
public render() {
return (
- <GestureView
- preferredPan={Types.PreferredPanGesture.Horizontal}
- onPanHorizontal={this.onPanHorizontal}
- onTap={this.onTap}>
- <View style={styles.holder}>
- <Animated.View style={[styles.knob, this.animatedStyle]} />
- </View>
- </GestureView>
+ <SwitchContainer ref={this.containerRef} onClick={this.handleClick}>
+ <Knob
+ isOn={this.state.isOn}
+ isPressed={this.state.isPressed}
+ onMouseDown={this.handleMouseDown}
+ />
+ </SwitchContainer>
);
}
- private onTap = (_gesture: Types.TapGestureState) => {
- this.setState(
- (state) => ({ isOn: !state.isOn, isPressed: false }),
- () => {
- this.notify();
- },
- );
+ private handleClick = () => {
+ if (!this.changedDuringPan) {
+ this.setState((state) => ({ isOn: !state.isOn }), this.notify);
+ }
+
+ // Needs to be reset to allow clicks on container after panning.
+ this.changedDuringPan = false;
};
- private onPanHorizontal = (gesture: Types.PanGestureState) => {
- if (this.isPanning) {
- if (gesture.isComplete) {
- this.isPanning = false;
+ private handleMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
+ this.isPanning = true;
+ this.startPos = event.clientX;
+ this.changedDuringPan = false;
- this.setState({ isPressed: false }, () => {
- if (this.startValue !== this.state.isOn) {
- this.notify();
- }
- });
- } else {
- const currentPos = { x: gesture.clientX, y: gesture.clientY };
- const nextOn = this.computeNextState(this.startPos, currentPos);
+ document.addEventListener('mouseup', this.handleMouseUp);
+ document.addEventListener('mousemove', this.handleMouseMove);
+ };
- if (this.state.isOn !== nextOn) {
- this.startPos = currentPos;
+ private handleMouseUp = (event: MouseEvent) => {
+ document.removeEventListener('mouseup', this.handleMouseUp);
+ document.removeEventListener('mousemove', this.handleMouseMove);
- this.setState({ isOn: nextOn });
- }
- }
- } else {
- if (gesture.isComplete) {
- return;
- }
+ this.setState({ isPressed: false });
+ this.isPanning = false;
+ // Reset changedDuringPan when onClick wont be called.
+ if (event.target instanceof Element && !this.containerRef.current?.contains(event.target)) {
+ this.changedDuringPan = false;
+ }
- this.isPanning = true;
- this.startPos = { x: gesture.clientX, y: gesture.clientY };
- this.startValue = this.state.isOn;
+ if (this.props.isOn !== this.state.isOn) {
+ this.notify();
+ }
+ };
+
+ private handleMouseMove = (event: MouseEvent) => {
+ if (this.isPanning) {
this.setState({ isPressed: true });
+
+ const nextOn = this.computeNextState(event.clientX);
+ if (this.state.isOn !== nextOn) {
+ this.startPos = event.clientX;
+ this.changedDuringPan = true;
+ this.setState({ isOn: nextOn });
+ }
}
};
- private computeNextState(initialPos: IPosition, currentPos: IPosition): boolean {
- if (currentPos.x < initialPos.x && this.state.isOn) {
+ private computeNextState(currentPos: number): boolean {
+ if (currentPos + PAN_DISTANCE < this.startPos && this.state.isOn) {
return false;
- } else if (currentPos.x > initialPos.x && !this.state.isOn) {
+ } else if (currentPos - PAN_DISTANCE > this.startPos && !this.state.isOn) {
return true;
} else {
return this.state.isOn;
}
}
- private computeKnobWidth(isPressed: boolean) {
- return isPressed ? SWITCH_PRESSED_WIDTH : SWITCH_DEFAULT_WIDTH;
- }
-
- private computeTranslation(isOn: boolean, isPressed: boolean) {
- if (isOn) {
- return isPressed ? 16 : 20;
- } else {
- return 0;
- }
- }
-
- private animate(onFinish?: (done: boolean) => void) {
- const duration = 200;
- const animation = Animated.parallel([
- Animated.timing(this.translationValue, {
- toValue: this.computeTranslation(this.state.isOn, this.state.isPressed),
- duration,
- }),
- Animated.timing(this.widthValue, {
- toValue: this.computeKnobWidth(this.state.isPressed),
- duration,
- }),
- Animated.timing(this.colorValue, {
- toValue: this.state.isOn ? 1 : 0,
- duration,
- }),
- ]);
-
- if (this.animation) {
- this.animation.stop();
- }
-
- animation.start((options) => {
- if (options.finished) {
- this.animation = undefined;
- }
-
- if (onFinish) {
- onFinish(options.finished);
- }
- });
-
- this.animation = animation;
- }
-
private notify() {
- if (this.props.onChange) {
- this.props.onChange(this.state.isOn);
- }
+ this.props.onChange?.(this.state.isOn);
}
}