From e986f40667cb3ab0a21df448cfa8ca11c79b6a12 Mon Sep 17 00:00:00 2001 From: Oskar Nyberg Date: Tue, 5 May 2020 11:15:58 +0200 Subject: [PATCH] Scroll to expose expanded items in location list --- gui/src/renderer/components/Accordion.tsx | 36 ++++++-- .../renderer/components/BridgeLocations.tsx | 8 +- gui/src/renderer/components/CityRow.tsx | 24 +++++- gui/src/renderer/components/CountryRow.tsx | 24 +++++- .../renderer/components/CustomScrollbars.tsx | 48 ++++++----- gui/src/renderer/components/ExitLocations.tsx | 8 +- gui/src/renderer/components/LocationList.tsx | 6 ++ .../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 ca3723584583..1a26d765cd14 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 { private containerRef = React.createRef(); + private contentRef = React.createRef(); public static defaultProps = { expanded: true, @@ -54,25 +57,30 @@ export default class Accordion extends React.Component { height={this.state.containerHeight} animationDuration={this.props.animationDuration} onTransitionEnd={this.onTransitionEnd}> - {this.state.mountChildren && this.props.children} + {this.state.mountChildren && this.props.children} ); } 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 { }); } - 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 7d464e092685..802797eb7769 100644 --- a/gui/src/renderer/components/BridgeLocations.tsx +++ b/gui/src/renderer/components/BridgeLocations.tsx @@ -21,6 +21,8 @@ interface IBridgeLocationsProps { selectedValue?: LiftedConstraint; selectedElementRef?: React.Ref; onSelect?: (value: LocationSelection) => 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')} - + ); }); diff --git a/gui/src/renderer/components/CityRow.tsx b/gui/src/renderer/components/CityRow.tsx index c8041abdbdf3..c015bd5f762c 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 { + private buttonRef = React.createRef(); + 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 { return ( { {hasChildren && } - {hasChildren && {this.props.children}} + {hasChildren && ( + + {this.props.children} + + )} ); } @@ -100,4 +114,12 @@ export default class CityRow extends Component { 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 026a55343d41..5218afdf73d6 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 { + private buttonRef = React.createRef(); + 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 { return ( { ) : null} - {hasChildren && {this.props.children}} + {hasChildren && ( + + {this.props.children} + + )} ); } @@ -109,4 +123,12 @@ export default class CountryRow extends Component { 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 fc91d7d23975..aca32e5f60dc 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 { private scrollableRef = React.createRef(); private trackRef = React.createRef(); private thumbRef = React.createRef(); - 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 { } } + 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 { } 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 { }; private handleEnterTrack = () => { - this.stopAutoHide(); + this.autoHideScheduler.cancel(); this.setState({ isTrackHovered: true, showScrollIndicators: true, @@ -326,10 +350,7 @@ export default class CustomScrollbars extends React.Component { } 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 { } 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 { }, 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 9fcc1e8ad418..e4174a0e7cb0 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; onSelect?: (value: LocationSelection) => 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}> - + ); }); diff --git a/gui/src/renderer/components/LocationList.tsx b/gui/src/renderer/components/LocationList.tsx index 2acb1adf6684..efd8969e4faa 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 { @@ -269,6 +271,8 @@ export class RelayLocations extends Component { expanded={this.isExpanded(countryLocation)} onSelect={this.handleSelection} onExpand={this.handleExpand} + onWillExpand={this.props.onWillExpand} + onTransitionEnd={this.props.onTransitionEnd} {...this.getCommonCellProps(countryLocation)}> {relayCountry.cities.map((relayCity) => { const cityLocation: RelayLocation = { @@ -283,6 +287,8 @@ export class RelayLocations extends Component { expanded={this.isExpanded(cityLocation)} onSelect={this.handleSelection} onExpand={this.handleExpand} + onWillExpand={this.props.onWillExpand} + onTransitionEnd={this.props.onTransitionEnd} {...this.getCommonCellProps(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 8ea980a5ae7b..de2f98128abf 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 { private scrollView = React.createRef(); + private spacePreAllocationViewRef = React.createRef(); private selectedExitLocationRef = React.createRef(); private selectedBridgeLocationRef = React.createRef(); @@ -128,27 +129,33 @@ export default class SelectLocation extends Component { - - {this.props.locationScope === LocationScope.relay ? ( - - ) : ( - - )} - + + + {this.props.locationScope === LocationScope.relay ? ( + + ) : ( + + )} + + @@ -219,4 +226,39 @@ export default class SelectLocation extends Component { 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 { + private ref = React.createRef(); + + public allocate(height: number) { + if (this.ref.current) { + this.minHeight = this.ref.current.offsetHeight + height + 'px'; + } + } + + public reset = () => { + this.minHeight = 'auto'; + }; + + public render() { + return
{this.props.children}
; + } + + private set minHeight(value: string) { + const element = this.ref.current; + if (element) { + element.style.minHeight = value; + } + } }