diff options
Diffstat (limited to 'gui/src/renderer/components')
| -rw-r--r-- | gui/src/renderer/components/Accordion.tsx | 160 | ||||
| -rw-r--r-- | gui/src/renderer/components/Account.tsx | 129 | ||||
| -rw-r--r-- | gui/src/renderer/components/AccountStyles.tsx | 110 | ||||
| -rw-r--r-- | gui/src/renderer/components/AccountTokenLabel.tsx | 5 | ||||
| -rw-r--r-- | gui/src/renderer/components/ClipboardLabel.tsx | 37 | ||||
| -rw-r--r-- | gui/src/renderer/components/ExpiredAccountErrorView.tsx | 8 | ||||
| -rw-r--r-- | gui/src/renderer/components/ExpiredAccountErrorViewStyles.tsx | 17 | ||||
| -rw-r--r-- | gui/src/renderer/components/Layout.tsx | 31 | ||||
| -rw-r--r-- | gui/src/renderer/components/LayoutStyles.tsx | 10 | ||||
| -rw-r--r-- | gui/src/renderer/components/Switch.tsx | 242 |
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); } } |
