summaryrefslogtreecommitdiffhomepage
path: root/gui/src
diff options
context:
space:
mode:
authorAndrej Mihajlov <and@mullvad.net>2019-09-09 11:50:46 +0200
committerAndrej Mihajlov <and@mullvad.net>2019-09-09 11:50:46 +0200
commitd8a2cffae6005837a2974e60ae78b7d3830123bd (patch)
tree01ea25ce1d38e5b668bd650be50c94f64abae26d /gui/src
parent998a616eff0c90019028f84bcbe90b90d7e57235 (diff)
parent5581a723950e2afd90086086e1dc4f50f9d71375 (diff)
downloadmullvadvpn-d8a2cffae6005837a2974e60ae78b7d3830123bd.tar.xz
mullvadvpn-d8a2cffae6005837a2974e60ae78b7d3830123bd.zip
Merge branch 'sticky-navigation'
Diffstat (limited to 'gui/src')
-rw-r--r--gui/src/renderer/components/NavigationBar.tsx483
1 files changed, 397 insertions, 86 deletions
diff --git a/gui/src/renderer/components/NavigationBar.tsx b/gui/src/renderer/components/NavigationBar.tsx
index dc5b6c91ea..9e81cb6b92 100644
--- a/gui/src/renderer/components/NavigationBar.tsx
+++ b/gui/src/renderer/components/NavigationBar.tsx
@@ -1,4 +1,5 @@
import * as React from 'react';
+import * as ReactDOM from 'react-dom';
import { Animated, Button, Component, Styles, Text, Types, UserInterface, View } from 'reactxp';
import { colors } from '../../config.json';
import CustomScrollbars, { IScrollEvent } from './CustomScrollbars';
@@ -16,9 +17,12 @@ const styles = {
flexDirection: 'row',
}),
separator: Styles.createViewStyle({
- borderStyle: 'solid',
- borderBottomWidth: 1,
- borderColor: 'rgba(0, 0, 0, 0.2)',
+ backgroundColor: 'rgba(0, 0, 0, 0.2)',
+ position: 'absolute',
+ bottom: 0,
+ left: 0,
+ right: 0,
+ height: 1,
}),
darwin: Styles.createViewStyle({
paddingTop: 24,
@@ -50,21 +54,6 @@ const styles = {
opacity: 0,
}),
},
- buttonBarItem: {
- default: Styles.createButtonStyle({
- cursor: 'default',
- }),
- content: Styles.createViewStyle({
- flexDirection: 'row',
- alignItems: 'center',
- }),
- label: Styles.createTextStyle({
- fontFamily: 'Open Sans',
- fontSize: 13,
- fontWeight: '600',
- color: colors.white60,
- }),
- },
closeBarItem: {
default: Styles.createViewStyle({
cursor: 'default',
@@ -96,39 +85,118 @@ const styles = {
marginRight: 8,
}),
},
+ scopeBar: {
+ container: Styles.createViewStyle({
+ flexDirection: 'row',
+ backgroundColor: colors.blue40,
+ borderRadius: 13,
+ }),
+ item: {
+ base: Styles.createButtonStyle({
+ cursor: 'default',
+ flex: 1,
+ paddingHorizontal: 8,
+ paddingVertical: 4,
+ }),
+ selected: Styles.createButtonStyle({
+ backgroundColor: colors.blue,
+ }),
+ hover: Styles.createButtonStyle({
+ backgroundColor: colors.blue40,
+ }),
+ label: Styles.createTextStyle({
+ fontFamily: 'Open Sans',
+ fontSize: 13,
+ color: colors.white,
+ textAlign: 'center',
+ }),
+ },
+ },
+ stickyContentHolder: Styles.createViewStyle({
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ backgroundColor: colors.darkBlue,
+ }),
};
interface INavigationScrollContextValue {
- scrollTop: number;
- onScroll: (event: IScrollEvent) => void;
+ navigationContainer?: NavigationContainer;
+ showsBarTitle: boolean;
+ showsBarSeparator: boolean;
}
const NavigationScrollContext = React.createContext<INavigationScrollContextValue>({
- scrollTop: 0,
- onScroll: (_event: IScrollEvent) => {
- // no-op
- },
+ showsBarTitle: false,
+ showsBarSeparator: false,
});
export class NavigationContainer extends Component {
public state = {
- scrollTop: 0,
+ navigationContainer: this,
+ showsBarTitle: false,
+ showsBarSeparator: false,
};
+ private scrollEventListeners: Array<(event: IScrollEvent) => void> = [];
+
+ public componentDidMount() {
+ this.updateBarAppearance({ scrollLeft: 0, scrollTop: 0 });
+ }
+
public render() {
return (
- <NavigationScrollContext.Provider
- value={{ scrollTop: this.state.scrollTop, onScroll: this.onScroll }}>
+ <NavigationScrollContext.Provider value={this.state}>
{this.props.children}
</NavigationScrollContext.Provider>
);
}
- private onScroll = (event: IScrollEvent) => {
- this.setState({
- scrollTop: event.scrollTop,
- });
+ public onScroll = (event: IScrollEvent) => {
+ this.notifyScrollEventListeners(event);
+ this.updateBarAppearance(event);
};
+
+ public addScrollEventListener(fn: (event: IScrollEvent) => void) {
+ const index = this.scrollEventListeners.indexOf(fn);
+ if (index === -1) {
+ this.scrollEventListeners.push(fn);
+ }
+ }
+
+ public removeScrollEventListener(fn: (event: IScrollEvent) => void) {
+ const index = this.scrollEventListeners.indexOf(fn);
+ if (index !== -1) {
+ this.scrollEventListeners.splice(index, 1);
+ }
+ }
+
+ private notifyScrollEventListeners(event: IScrollEvent) {
+ this.scrollEventListeners.forEach((listener) => listener(event));
+ }
+
+ private updateBarAppearance(event: IScrollEvent) {
+ // detect if any of child elements provide a sticky context
+ // in that case the navigation bar does not draw the separator line
+ // since the sticky content is expected to include it.
+ const hasSticky = React.Children.toArray(this.props.children).some((child) => {
+ return React.isValidElement(child) && child.type === StickyContentContainer;
+ });
+
+ // that's where SettingsHeader.HeaderTitle intersects the navigation bar
+ const showsBarSeparator = event.scrollTop > 11 && !hasSticky;
+
+ // that's when SettingsHeader.HeaderTitle goes behind the navigation bar
+ const showsBarTitle = event.scrollTop > 39;
+
+ if (
+ this.state.showsBarSeparator !== showsBarSeparator ||
+ this.state.showsBarTitle !== showsBarTitle
+ ) {
+ this.setState({ showsBarSeparator, showsBarTitle });
+ }
+ }
}
interface INavigationScrollbarsProps {
@@ -136,32 +204,48 @@ interface INavigationScrollbarsProps {
style?: React.CSSProperties;
children?: React.ReactNode;
}
+
export const NavigationScrollbars = React.forwardRef(function NavigationScrollbarsT(
props: INavigationScrollbarsProps,
ref?: React.Ref<CustomScrollbars>,
) {
return (
- <NavigationScrollContext.Consumer>
- {(context) => {
- const { style, children, ...otherProps } = props;
- const wrappedOnScroll = (scroll: IScrollEvent) => {
- context.onScroll(scroll);
-
- if (otherProps.onScroll) {
- otherProps.onScroll(scroll);
- }
- };
-
- return (
- <CustomScrollbars ref={ref} style={style} onScroll={wrappedOnScroll}>
- {children}
- </CustomScrollbars>
- );
- }}
- </NavigationScrollContext.Consumer>
+ <PrivateNavigationScrollbars forwardedRef={ref} {...props}>
+ {props.children}
+ </PrivateNavigationScrollbars>
);
});
+interface IPrivateNavigationScrollbars extends INavigationScrollbarsProps {
+ forwardedRef?: React.Ref<CustomScrollbars>;
+}
+
+class PrivateNavigationScrollbars extends Component<IPrivateNavigationScrollbars> {
+ public static contextType = NavigationScrollContext;
+ public context!: React.ContextType<typeof NavigationScrollContext>;
+
+ public render() {
+ return (
+ <CustomScrollbars
+ ref={this.props.forwardedRef}
+ style={this.props.style}
+ onScroll={this.onScroll}>
+ {this.props.children}
+ </CustomScrollbars>
+ );
+ }
+
+ private onScroll = (scroll: IScrollEvent) => {
+ if (this.context.navigationContainer) {
+ this.context.navigationContainer.onScroll(scroll);
+ }
+
+ if (this.props.onScroll) {
+ this.props.onScroll(scroll);
+ }
+ };
+}
+
interface IPrivateTitleBarItemProps {
visible: boolean;
titleAdjustment: number;
@@ -259,6 +343,258 @@ class PrivateBarItemAnimationContainer extends Component<IPrivateBarItemAnimatio
}
}
+interface IStickyContentContext {
+ container: HTMLDivElement | null;
+ holder: React.Ref<View>;
+ isSticky: boolean;
+}
+
+const StickyContentContext = React.createContext<IStickyContentContext>({
+ container: null,
+ holder: React.createRef<View>(),
+ isSticky: false,
+});
+
+export class StickyContentContainer extends Component<{
+ style: Types.StyleRuleSet<Types.ViewStyle>;
+}> {
+ public static contextType = NavigationScrollContext;
+ public context!: React.ContextType<typeof NavigationScrollContext>;
+
+ public state = {
+ container: null,
+ holder: React.createRef<View>(),
+ isSticky: false,
+ };
+
+ public componentDidMount() {
+ if (this.context.navigationContainer) {
+ this.context.navigationContainer.addScrollEventListener(this.onScroll);
+ }
+ }
+
+ public componentWillUnmount() {
+ if (this.context.navigationContainer) {
+ this.context.navigationContainer.removeScrollEventListener(this.onScroll);
+ }
+ }
+
+ public render() {
+ return (
+ <div
+ ref={this.onRef}
+ style={{
+ position: 'relative',
+ display: 'flex',
+ flexDirection: 'column',
+ overflow: 'hidden',
+ }}>
+ <View style={this.props.style}>
+ <StickyContentContext.Provider value={this.state}>
+ {this.props.children}
+ </StickyContentContext.Provider>
+ </View>
+ </div>
+ );
+ }
+
+ private onScroll = async (_scrollEvent: IScrollEvent) => {
+ const holder = this.state.holder.current;
+
+ if (holder) {
+ let layout: Types.LayoutInfo;
+
+ try {
+ layout = await UserInterface.measureLayoutRelativeToAncestor(holder, this);
+ } catch {
+ // TODO: handle error
+ return;
+ }
+
+ const isSticky = layout.y <= 0;
+
+ if (this.state.isSticky !== isSticky) {
+ this.setState({ isSticky });
+ }
+ }
+ };
+
+ private onRef = (ref: HTMLDivElement | null) => {
+ this.setState({ container: ref });
+ };
+}
+
+interface IScopeBarProps {
+ defaultSelectedIndex: number;
+ onChange?: (selectedIndex: number) => void;
+ style?: Types.StyleRuleSet<Types.ViewStyle>;
+ children: React.ReactNode;
+}
+
+interface IScopeBarState {
+ selectedIndex: number;
+}
+
+export class ScopeBar extends Component<IScopeBarProps, IScopeBarState> {
+ public static defaultProps: Partial<IScopeBarProps> = {
+ defaultSelectedIndex: 0,
+ };
+
+ public state = {
+ selectedIndex: 0,
+ };
+
+ constructor(props: IScopeBarProps) {
+ super(props);
+
+ this.state = {
+ selectedIndex: props.defaultSelectedIndex,
+ };
+ }
+
+ public render() {
+ return (
+ <View style={[styles.scopeBar.container, this.props.style]}>
+ {React.Children.map(this.props.children, (child, index) => {
+ if (React.isValidElement(child)) {
+ return React.cloneElement(child, {
+ ...(child.props || {}),
+ selected: index === this.state.selectedIndex,
+ onPress: this.makePressHandler(index),
+ });
+ } else {
+ return undefined;
+ }
+ })}
+ </View>
+ );
+ }
+
+ public shouldComponentUpdate(nextProps: IScopeBarProps, nextState: IScopeBarState) {
+ return (
+ this.props.onChange !== nextProps.onChange ||
+ this.props.style !== nextProps.style ||
+ this.props.children !== nextProps.children ||
+ this.state.selectedIndex !== nextState.selectedIndex
+ );
+ }
+
+ private makePressHandler(index: number) {
+ return () => {
+ if (this.state.selectedIndex !== index) {
+ this.setState({ selectedIndex: index }, () => {
+ if (this.props.onChange) {
+ this.props.onChange(index);
+ }
+ });
+ }
+ };
+ }
+}
+
+interface IStickyContentHolderProps {
+ style?: Types.ViewStyleRuleSet;
+}
+
+interface IStickyContentHolderState {
+ contentHeight: number;
+}
+
+export class StickyContentHolder extends Component<
+ IStickyContentHolderProps,
+ IStickyContentHolderState
+> {
+ public state = {
+ contentHeight: 0,
+ };
+
+ public render() {
+ return (
+ <StickyContentContext.Consumer>
+ {(stickyContext) => {
+ const contentStyle = stickyContext.isSticky ? styles.stickyContentHolder : undefined;
+ const contentPlaceholderStyle = stickyContext.isSticky
+ ? Styles.createViewStyle(
+ {
+ height: this.state.contentHeight,
+ },
+ false,
+ )
+ : undefined;
+
+ const children = (
+ <View style={contentStyle} onLayout={this.onLayout}>
+ {this.props.children}
+ {stickyContext.isSticky ? <NavigationBarSeparator /> : undefined}
+ </View>
+ );
+
+ return (
+ <View style={this.props.style} ref={stickyContext.holder}>
+ {stickyContext.isSticky && stickyContext.container ? (
+ <React.Fragment>
+ <View style={contentPlaceholderStyle} />
+ {ReactDOM.createPortal(children, stickyContext.container)}
+ </React.Fragment>
+ ) : (
+ children
+ )}
+ </View>
+ );
+ }}
+ </StickyContentContext.Consumer>
+ );
+ }
+
+ private onLayout = async (layout: Types.LayoutInfo) => {
+ if (this.state.contentHeight !== layout.height) {
+ this.setState({ contentHeight: layout.height });
+ }
+ };
+}
+
+interface IScopeBarItemProps {
+ children?: React.ReactText;
+ selected?: boolean;
+ onPress?: () => void;
+}
+
+export class ScopeBarItem extends Component<IScopeBarItemProps> {
+ public state = {
+ isHovered: false,
+ };
+
+ public render() {
+ const hoverStyle = this.props.selected
+ ? styles.scopeBar.item.selected
+ : this.state.isHovered
+ ? styles.scopeBar.item.hover
+ : undefined;
+
+ return (
+ <Button
+ style={[styles.scopeBar.item.base, hoverStyle]}
+ onHoverStart={this.onHoverStart}
+ onHoverEnd={this.onHoverEnd}
+ onPress={this.props.onPress}>
+ <Text style={styles.scopeBar.item.label}>{this.props.children}</Text>
+ </Button>
+ );
+ }
+
+ private onHoverStart = () => {
+ this.setState({ isHovered: true });
+ };
+
+ private onHoverEnd = () => {
+ this.setState({ isHovered: false });
+ };
+}
+
+function NavigationBarSeparator() {
+ return <View style={styles.navigationBar.separator} />;
+}
+
interface INavigationBarProps {
children?: React.ReactNode;
}
@@ -270,7 +606,10 @@ export const NavigationBar = React.forwardRef(function NavigationBarT(
return (
<NavigationScrollContext.Consumer>
{(context) => (
- <PrivateNavigationBar ref={ref} scrollTop={context.scrollTop}>
+ <PrivateNavigationBar
+ ref={ref}
+ showsBarTitle={context.showsBarTitle}
+ showsBarSeparator={context.showsBarSeparator}>
{props.children}
</PrivateNavigationBar>
)}
@@ -279,14 +618,13 @@ export const NavigationBar = React.forwardRef(function NavigationBarT(
});
interface IPrivateNavigationBarProps {
- scrollTop: number;
+ showsBarSeparator: boolean;
+ showsBarTitle: boolean;
children?: React.ReactNode;
}
interface IPrivateNavigationBarState {
titleAdjustment: number;
- showsBarSeparator: boolean;
- showsBarTitle: boolean;
}
const PrivateTitleBarItemContext = React.createContext({
@@ -300,31 +638,8 @@ class PrivateNavigationBar extends Component<
IPrivateNavigationBarProps,
IPrivateNavigationBarState
> {
- public static defaultProps: Partial<IPrivateNavigationBarProps> = {
- scrollTop: 0,
- };
-
- public static getDerivedStateFromProps(
- props: IPrivateNavigationBarProps,
- state: IPrivateNavigationBarState,
- ) {
- // that's where SettingsHeader.HeaderTitle intersects the navigation bar
- const showsBarSeparator = props.scrollTop > 11;
-
- // that's when SettingsHeader.HeaderTitle goes behind the navigation bar
- const showsBarTitle = props.scrollTop > 30;
-
- return {
- ...state,
- showsBarSeparator,
- showsBarTitle,
- };
- }
-
public state: IPrivateNavigationBarState = {
titleAdjustment: 0,
- showsBarSeparator: false,
- showsBarTitle: false,
};
private titleViewRef = React.createRef<PrivateTitleBarItem>();
@@ -336,31 +651,27 @@ class PrivateNavigationBar extends Component<
) {
return (
this.props.children !== nextProps.children ||
- this.state.titleAdjustment !== nextState.titleAdjustment ||
- this.state.showsBarSeparator !== nextState.showsBarSeparator ||
- this.state.showsBarTitle !== nextState.showsBarTitle
+ this.props.showsBarSeparator !== nextProps.showsBarSeparator ||
+ this.props.showsBarTitle !== nextProps.showsBarTitle ||
+ this.state.titleAdjustment !== nextState.titleAdjustment
);
}
public render() {
return (
- <View
- style={[
- styles.navigationBar.default,
- this.state.showsBarSeparator ? styles.navigationBar.separator : undefined,
- this.getPlatformStyle(),
- ]}>
+ <View style={[styles.navigationBar.default, this.getPlatformStyle()]}>
<View style={styles.navigationBar.content} onLayout={this.onLayout}>
<PrivateTitleBarItemContext.Provider
value={{
titleAdjustment: this.state.titleAdjustment,
- visible: this.state.showsBarTitle,
+ visible: this.props.showsBarTitle,
titleRef: this.titleViewRef,
measuringTextRef: this.measuringTextRef,
}}>
{this.props.children}
</PrivateTitleBarItemContext.Provider>
</View>
+ {this.props.showsBarSeparator && <NavigationBarSeparator />}
</View>
);
}