diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2020-05-05 11:15:58 +0200 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2020-05-08 14:37:07 +0200 |
| commit | e986f40667cb3ab0a21df448cfa8ca11c79b6a12 (patch) | |
| tree | a8681db3651c8241d4b0e51dca9de640d4f60bd3 /gui/src | |
| parent | f06dfd57569e737a4b8815e7034be18eae6703d0 (diff) | |
| download | mullvadvpn-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.tsx | 36 | ||||
| -rw-r--r-- | gui/src/renderer/components/BridgeLocations.tsx | 8 | ||||
| -rw-r--r-- | gui/src/renderer/components/CityRow.tsx | 24 | ||||
| -rw-r--r-- | gui/src/renderer/components/CountryRow.tsx | 24 | ||||
| -rw-r--r-- | gui/src/renderer/components/CustomScrollbars.tsx | 48 | ||||
| -rw-r--r-- | gui/src/renderer/components/ExitLocations.tsx | 8 | ||||
| -rw-r--r-- | gui/src/renderer/components/LocationList.tsx | 6 | ||||
| -rw-r--r-- | gui/src/renderer/components/SelectLocation.tsx | 84 |
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; + } + } } |
