summaryrefslogtreecommitdiffhomepage
path: root/gui/src
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2020-05-05 11:15:58 +0200
committerOskar Nyberg <oskar@mullvad.net>2020-05-08 14:37:07 +0200
commite986f40667cb3ab0a21df448cfa8ca11c79b6a12 (patch)
treea8681db3651c8241d4b0e51dca9de640d4f60bd3 /gui/src
parentf06dfd57569e737a4b8815e7034be18eae6703d0 (diff)
downloadmullvadvpn-e986f40667cb3ab0a21df448cfa8ca11c79b6a12.tar.xz
mullvadvpn-e986f40667cb3ab0a21df448cfa8ca11c79b6a12.zip
Scroll to expose expanded items in location list
Diffstat (limited to 'gui/src')
-rw-r--r--gui/src/renderer/components/Accordion.tsx36
-rw-r--r--gui/src/renderer/components/BridgeLocations.tsx8
-rw-r--r--gui/src/renderer/components/CityRow.tsx24
-rw-r--r--gui/src/renderer/components/CountryRow.tsx24
-rw-r--r--gui/src/renderer/components/CustomScrollbars.tsx48
-rw-r--r--gui/src/renderer/components/ExitLocations.tsx8
-rw-r--r--gui/src/renderer/components/LocationList.tsx6
-rw-r--r--gui/src/renderer/components/SelectLocation.tsx84
8 files changed, 186 insertions, 52 deletions
diff --git a/gui/src/renderer/components/Accordion.tsx b/gui/src/renderer/components/Accordion.tsx
index ca37235845..1a26d765cd 100644
--- a/gui/src/renderer/components/Accordion.tsx
+++ b/gui/src/renderer/components/Accordion.tsx
@@ -5,6 +5,8 @@ interface IProps {
expanded: boolean;
animationDuration: number;
children?: React.ReactNode;
+ onWillExpand?: (contentHeight: number) => void;
+ onTransitionEnd?: () => void;
}
interface IState {
@@ -28,6 +30,7 @@ const Content = styled.div({
export default class Accordion extends React.Component<IProps, IState> {
private containerRef = React.createRef<HTMLDivElement>();
+ private contentRef = React.createRef<HTMLDivElement>();
public static defaultProps = {
expanded: true,
@@ -54,25 +57,30 @@ export default class Accordion extends React.Component<IProps, IState> {
height={this.state.containerHeight}
animationDuration={this.props.animationDuration}
onTransitionEnd={this.onTransitionEnd}>
- <Content>{this.state.mountChildren && this.props.children}</Content>
+ <Content ref={this.contentRef}>{this.state.mountChildren && this.props.children}</Content>
</Container>
);
}
private expand() {
// Make sure the children are mounted first before expanding the accordion
+ this.mountChildren(() => {
+ this.onWillExpand();
+ this.setState({ containerHeight: this.getContentHeightWithUnit() });
+ });
+ }
+
+ private mountChildren(childrenDidMount: () => void) {
if (!this.state.mountChildren) {
- this.setState({ mountChildren: true }, () => {
- this.setState({ containerHeight: this.getContentHeight() });
- });
+ this.setState({ mountChildren: true }, childrenDidMount);
} else {
- this.setState({ containerHeight: this.getContentHeight() });
+ childrenDidMount();
}
}
private collapse() {
// First change height to height in px since it's not possible to transition to/from auto
- this.setState({ containerHeight: this.getContentHeight() }, () => {
+ this.setState({ containerHeight: this.getContentHeightWithUnit() }, () => {
// Make sure new height has been applied
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
this.containerRef.current?.offsetHeight;
@@ -80,11 +88,23 @@ export default class Accordion extends React.Component<IProps, IState> {
});
}
- private getContentHeight(): string {
- return (this.containerRef.current?.scrollHeight ?? 0) + 'px';
+ private getContentHeightWithUnit(): string {
+ return (this.getContentHeight() ?? 0) + 'px';
+ }
+
+ private getContentHeight(): number | undefined {
+ return this.contentRef.current?.offsetHeight;
+ }
+
+ private onWillExpand() {
+ const contentHeight = this.getContentHeight();
+ if (contentHeight) {
+ this.props.onWillExpand?.(contentHeight);
+ }
}
private onTransitionEnd = () => {
+ this.props.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/BridgeLocations.tsx b/gui/src/renderer/components/BridgeLocations.tsx
index 7d464e0926..802797eb77 100644
--- a/gui/src/renderer/components/BridgeLocations.tsx
+++ b/gui/src/renderer/components/BridgeLocations.tsx
@@ -21,6 +21,8 @@ interface IBridgeLocationsProps {
selectedValue?: LiftedConstraint<RelayLocation>;
selectedElementRef?: React.Ref<React.ReactInstance>;
onSelect?: (value: LocationSelection<SpecialBridgeLocationType>) => void;
+ onWillExpand?: (locationRect: DOMRect, expandedContentHeight: number) => void;
+ onTransitionEnd?: () => void;
}
const BridgeLocations = React.forwardRef(function BridgeLocationsT(
@@ -49,7 +51,11 @@ const BridgeLocations = React.forwardRef(function BridgeLocationsT(
{messages.pgettext('select-location-view', 'Closest to exit server')}
</SpecialLocation>
</SpecialLocations>
- <RelayLocations source={props.source} />
+ <RelayLocations
+ source={props.source}
+ onWillExpand={props.onWillExpand}
+ onTransitionEnd={props.onTransitionEnd}
+ />
</LocationList>
);
});
diff --git a/gui/src/renderer/components/CityRow.tsx b/gui/src/renderer/components/CityRow.tsx
index c8041abdbd..c015bd5f76 100644
--- a/gui/src/renderer/components/CityRow.tsx
+++ b/gui/src/renderer/components/CityRow.tsx
@@ -1,4 +1,5 @@
import * as React from 'react';
+import ReactDOM from 'react-dom';
import { Component, Styles, Types, View } from 'reactxp';
import { colors } from '../../config.json';
import { compareRelayLocation, RelayLocation } from '../../shared/daemon-rpc-types';
@@ -18,6 +19,8 @@ interface IProps {
expanded: boolean;
onSelect?: (location: RelayLocation) => void;
onExpand?: (location: RelayLocation, value: boolean) => void;
+ onWillExpand?: (locationRect: DOMRect, expandedContentHeight: number) => void;
+ onTransitionEnd?: () => void;
children?: RelayRowElement | RelayRowElement[];
}
@@ -30,6 +33,8 @@ const styles = {
};
export default class CityRow extends Component<IProps> {
+ private buttonRef = React.createRef<Cell.CellButton>();
+
public static compareProps(oldProps: IProps, nextProps: IProps): boolean {
if (React.Children.count(oldProps.children) !== React.Children.count(nextProps.children)) {
return false;
@@ -70,6 +75,7 @@ export default class CityRow extends Component<IProps> {
return (
<View>
<Cell.CellButton
+ ref={this.buttonRef}
onPress={this.handlePress}
disabled={!this.props.hasActiveRelays}
selected={this.props.selected}
@@ -83,7 +89,15 @@ export default class CityRow extends Component<IProps> {
{hasChildren && <ChevronButton onPress={this.toggleCollapse} up={this.props.expanded} />}
</Cell.CellButton>
- {hasChildren && <Accordion expanded={this.props.expanded}>{this.props.children}</Accordion>}
+ {hasChildren && (
+ <Accordion
+ expanded={this.props.expanded}
+ onWillExpand={this.onWillExpand}
+ onTransitionEnd={this.props.onTransitionEnd}
+ animationDuration={150}>
+ {this.props.children}
+ </Accordion>
+ )}
</View>
);
}
@@ -100,4 +114,12 @@ export default class CityRow extends Component<IProps> {
this.props.onSelect(this.props.location);
}
};
+
+ private onWillExpand = (nextHeight: number) => {
+ const buttonNode = ReactDOM.findDOMNode(this.buttonRef.current);
+ if (buttonNode instanceof HTMLElement) {
+ const buttonRect = buttonNode.getBoundingClientRect();
+ this.props.onWillExpand?.(buttonRect, nextHeight);
+ }
+ };
}
diff --git a/gui/src/renderer/components/CountryRow.tsx b/gui/src/renderer/components/CountryRow.tsx
index 026a55343d..5218afdf73 100644
--- a/gui/src/renderer/components/CountryRow.tsx
+++ b/gui/src/renderer/components/CountryRow.tsx
@@ -1,4 +1,5 @@
import * as React from 'react';
+import ReactDOM from 'react-dom';
import { Component, Styles, Types, View } from 'reactxp';
import { compareRelayLocation, RelayLocation } from '../../shared/daemon-rpc-types';
import Accordion from './Accordion';
@@ -17,6 +18,8 @@ interface IProps {
expanded: boolean;
onSelect?: (location: RelayLocation) => void;
onExpand?: (location: RelayLocation, value: boolean) => void;
+ onWillExpand?: (locationRect: DOMRect, expandedContentHeight: number) => void;
+ onTransitionEnd?: () => void;
children?: CityRowElement | CityRowElement[];
}
@@ -32,6 +35,8 @@ const styles = {
};
export default class CountryRow extends Component<IProps> {
+ private buttonRef = React.createRef<Cell.CellButton>();
+
public static compareProps(oldProps: IProps, nextProps: IProps) {
if (React.Children.count(oldProps.children) !== React.Children.count(nextProps.children)) {
return false;
@@ -78,6 +83,7 @@ export default class CountryRow extends Component<IProps> {
return (
<View style={styles.container}>
<Cell.CellButton
+ ref={this.buttonRef}
style={styles.base}
onPress={this.handlePress}
disabled={!this.props.hasActiveRelays}
@@ -92,7 +98,15 @@ export default class CountryRow extends Component<IProps> {
) : null}
</Cell.CellButton>
- {hasChildren && <Accordion expanded={this.props.expanded}>{this.props.children}</Accordion>}
+ {hasChildren && (
+ <Accordion
+ expanded={this.props.expanded}
+ onWillExpand={this.onWillExpand}
+ onTransitionEnd={this.props.onTransitionEnd}
+ animationDuration={150}>
+ {this.props.children}
+ </Accordion>
+ )}
</View>
);
}
@@ -109,4 +123,12 @@ export default class CountryRow extends Component<IProps> {
this.props.onSelect(this.props.location);
}
};
+
+ private onWillExpand = (nextHeight: number) => {
+ const buttonNode = ReactDOM.findDOMNode(this.buttonRef.current);
+ if (buttonNode instanceof HTMLElement) {
+ const buttonRect = buttonNode.getBoundingClientRect();
+ this.props.onWillExpand?.(buttonRect, nextHeight);
+ }
+ };
}
diff --git a/gui/src/renderer/components/CustomScrollbars.tsx b/gui/src/renderer/components/CustomScrollbars.tsx
index fc91d7d239..aca32e5f60 100644
--- a/gui/src/renderer/components/CustomScrollbars.tsx
+++ b/gui/src/renderer/components/CustomScrollbars.tsx
@@ -1,4 +1,5 @@
import * as React from 'react';
+import { Scheduler } from '../../shared/scheduler';
const AUTOHIDE_TIMEOUT = 1000;
@@ -54,7 +55,7 @@ export default class CustomScrollbars extends React.Component<IProps, IState> {
private scrollableRef = React.createRef<HTMLDivElement>();
private trackRef = React.createRef<HTMLDivElement>();
private thumbRef = React.createRef<HTMLDivElement>();
- private autoHideTimer?: NodeJS.Timeout;
+ private autoHideScheduler = new Scheduler();
public scrollToTop() {
const scrollable = this.scrollableRef.current;
@@ -86,6 +87,29 @@ export default class CustomScrollbars extends React.Component<IProps, IState> {
}
}
+ public scrollIntoView(elementRect: DOMRect) {
+ const scrollable = this.scrollableRef.current;
+ if (scrollable) {
+ const scrollableRect = scrollable.getBoundingClientRect();
+ // The element position needs to be relative to the parent, not the document
+ const elementTop = elementRect.top - scrollableRect.top;
+ const bottomOverflow = elementTop + elementRect.height - scrollableRect.height;
+
+ let scrollDistance = 0;
+ if (elementTop < 0) {
+ scrollDistance = elementTop;
+ } else if (bottomOverflow > 0) {
+ // Prevent the elements top from being scrolled out of the visible area
+ scrollDistance = Math.min(bottomOverflow, elementTop);
+ }
+
+ scrollable.scrollBy({
+ top: scrollDistance,
+ behavior: 'smooth',
+ });
+ }
+ }
+
public getScrollPosition(): [number, number] {
const scroll = this.scrollableRef.current;
if (scroll) {
@@ -130,7 +154,7 @@ export default class CustomScrollbars extends React.Component<IProps, IState> {
}
public componentWillUnmount() {
- this.stopAutoHide();
+ this.autoHideScheduler.cancel();
document.removeEventListener('mousemove', this.handleMouseMove);
document.removeEventListener('mouseup', this.handleMouseUp);
@@ -206,7 +230,7 @@ export default class CustomScrollbars extends React.Component<IProps, IState> {
};
private handleEnterTrack = () => {
- this.stopAutoHide();
+ this.autoHideScheduler.cancel();
this.setState({
isTrackHovered: true,
showScrollIndicators: true,
@@ -326,10 +350,7 @@ export default class CustomScrollbars extends React.Component<IProps, IState> {
}
private startAutoHide() {
- if (this.autoHideTimer) {
- clearTimeout(this.autoHideTimer);
- }
- this.autoHideTimer = global.setTimeout(() => {
+ this.autoHideScheduler.schedule(() => {
this.setState({
showScrollIndicators: false,
showTrack: false,
@@ -339,11 +360,7 @@ export default class CustomScrollbars extends React.Component<IProps, IState> {
}
private startAutoShrink() {
- if (this.autoHideTimer) {
- clearTimeout(this.autoHideTimer);
- }
-
- this.autoHideTimer = global.setTimeout(() => {
+ this.autoHideScheduler.schedule(() => {
this.setState({
showTrack: false,
isWide: false,
@@ -351,13 +368,6 @@ export default class CustomScrollbars extends React.Component<IProps, IState> {
}, AUTOHIDE_TIMEOUT);
}
- private stopAutoHide() {
- if (this.autoHideTimer) {
- clearTimeout(this.autoHideTimer);
- this.autoHideTimer = undefined;
- }
- }
-
private isPointInsideOfElement(element: HTMLElement, point: { x: number; y: number }) {
const rect = element.getBoundingClientRect();
return (
diff --git a/gui/src/renderer/components/ExitLocations.tsx b/gui/src/renderer/components/ExitLocations.tsx
index 9fcc1e8ad4..e4174a0e7c 100644
--- a/gui/src/renderer/components/ExitLocations.tsx
+++ b/gui/src/renderer/components/ExitLocations.tsx
@@ -13,6 +13,8 @@ interface IExitLocationsProps {
selectedValue?: RelayLocation;
selectedElementRef?: React.Ref<React.ReactInstance>;
onSelect?: (value: LocationSelection<never>) => void;
+ onWillExpand?: (locationRect: DOMRect, expandedContentHeight: number) => void;
+ onTransitionEnd?: () => void;
}
const ExitLocations = React.forwardRef(function ExitLocationsT(
@@ -30,7 +32,11 @@ const ExitLocations = React.forwardRef(function ExitLocationsT(
selectedValue={selectedValue}
selectedElementRef={props.selectedElementRef}
onSelect={props.onSelect}>
- <RelayLocations source={props.source} />
+ <RelayLocations
+ source={props.source}
+ onWillExpand={props.onWillExpand}
+ onTransitionEnd={props.onTransitionEnd}
+ />
</LocationList>
);
});
diff --git a/gui/src/renderer/components/LocationList.tsx b/gui/src/renderer/components/LocationList.tsx
index 2acb1adf66..efd8969e4f 100644
--- a/gui/src/renderer/components/LocationList.tsx
+++ b/gui/src/renderer/components/LocationList.tsx
@@ -246,6 +246,8 @@ interface IRelayLocationsProps {
expandedItems?: RelayLocation[];
onSelect?: (location: RelayLocation) => void;
onExpand?: (location: RelayLocation, expand: boolean) => void;
+ onWillExpand?: (locationRect: DOMRect, expandedContentHeight: number) => void;
+ onTransitionEnd?: () => void;
}
interface ICommonCellProps<T> {
@@ -269,6 +271,8 @@ export class RelayLocations extends Component<IRelayLocationsProps> {
expanded={this.isExpanded(countryLocation)}
onSelect={this.handleSelection}
onExpand={this.handleExpand}
+ onWillExpand={this.props.onWillExpand}
+ onTransitionEnd={this.props.onTransitionEnd}
{...this.getCommonCellProps<CountryRow>(countryLocation)}>
{relayCountry.cities.map((relayCity) => {
const cityLocation: RelayLocation = {
@@ -283,6 +287,8 @@ export class RelayLocations extends Component<IRelayLocationsProps> {
expanded={this.isExpanded(cityLocation)}
onSelect={this.handleSelection}
onExpand={this.handleExpand}
+ onWillExpand={this.props.onWillExpand}
+ onTransitionEnd={this.props.onTransitionEnd}
{...this.getCommonCellProps<CityRow>(cityLocation)}>
{relayCity.relays.map((relay) => {
const relayLocation: RelayLocation = {
diff --git a/gui/src/renderer/components/SelectLocation.tsx b/gui/src/renderer/components/SelectLocation.tsx
index 8ea980a5ae..de2f98128a 100644
--- a/gui/src/renderer/components/SelectLocation.tsx
+++ b/gui/src/renderer/components/SelectLocation.tsx
@@ -44,6 +44,7 @@ interface ISelectLocationSnapshot {
export default class SelectLocation extends Component<IProps> {
private scrollView = React.createRef<CustomScrollbars>();
+ private spacePreAllocationViewRef = React.createRef<SpacePreAllocationView>();
private selectedExitLocationRef = React.createRef<React.ReactInstance>();
private selectedBridgeLocationRef = React.createRef<React.ReactInstance>();
@@ -128,27 +129,33 @@ export default class SelectLocation extends Component<IProps> {
</NavigationBar>
<View style={styles.container}>
<NavigationScrollbars ref={this.scrollView}>
- <View style={styles.content}>
- {this.props.locationScope === LocationScope.relay ? (
- <ExitLocations
- ref={this.exitLocationList}
- source={this.props.relayLocations}
- defaultExpandedLocations={this.getExpandedLocationsFromSnapshot()}
- selectedValue={this.props.selectedExitLocation}
- selectedElementRef={this.selectedExitLocationRef}
- onSelect={this.onSelectExitLocation}
- />
- ) : (
- <BridgeLocations
- ref={this.bridgeLocationList}
- source={this.props.bridgeLocations}
- defaultExpandedLocations={this.getExpandedLocationsFromSnapshot()}
- selectedValue={this.props.selectedBridgeLocation}
- selectedElementRef={this.selectedBridgeLocationRef}
- onSelect={this.onSelectBridgeLocation}
- />
- )}
- </View>
+ <SpacePreAllocationView ref={this.spacePreAllocationViewRef}>
+ <View style={styles.content}>
+ {this.props.locationScope === LocationScope.relay ? (
+ <ExitLocations
+ ref={this.exitLocationList}
+ source={this.props.relayLocations}
+ defaultExpandedLocations={this.getExpandedLocationsFromSnapshot()}
+ selectedValue={this.props.selectedExitLocation}
+ selectedElementRef={this.selectedExitLocationRef}
+ onSelect={this.onSelectExitLocation}
+ onWillExpand={this.onWillExpand}
+ onTransitionEnd={this.spacePreAllocationViewRef.current?.reset}
+ />
+ ) : (
+ <BridgeLocations
+ ref={this.bridgeLocationList}
+ source={this.props.bridgeLocations}
+ defaultExpandedLocations={this.getExpandedLocationsFromSnapshot()}
+ selectedValue={this.props.selectedBridgeLocation}
+ selectedElementRef={this.selectedBridgeLocationRef}
+ onSelect={this.onSelectBridgeLocation}
+ onWillExpand={this.onWillExpand}
+ onTransitionEnd={this.spacePreAllocationViewRef.current?.reset}
+ />
+ )}
+ </View>
+ </SpacePreAllocationView>
</NavigationScrollbars>
</View>
</NavigationContainer>
@@ -219,4 +226,39 @@ export default class SelectLocation extends Component<IProps> {
this.props.onSelectClosestToExit();
}
};
+
+ private onWillExpand = (locationRect: DOMRect, expandedContentHeight: number) => {
+ locationRect.height += expandedContentHeight;
+ this.spacePreAllocationViewRef.current?.allocate(expandedContentHeight);
+ this.scrollView.current?.scrollIntoView(locationRect);
+ };
+}
+
+interface ISpacePreAllocationView {
+ children?: React.ReactNode;
+}
+
+class SpacePreAllocationView extends Component<ISpacePreAllocationView> {
+ private ref = React.createRef<HTMLDivElement>();
+
+ public allocate(height: number) {
+ if (this.ref.current) {
+ this.minHeight = this.ref.current.offsetHeight + height + 'px';
+ }
+ }
+
+ public reset = () => {
+ this.minHeight = 'auto';
+ };
+
+ public render() {
+ return <div ref={this.ref}>{this.props.children}</div>;
+ }
+
+ private set minHeight(value: string) {
+ const element = this.ref.current;
+ if (element) {
+ element.style.minHeight = value;
+ }
+ }
}