diff --git a/gui/src/renderer/components/Accordion.tsx b/gui/src/renderer/components/Accordion.tsx index ca3723584583..a33952c0cd85 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?: (nextHeight: number) => void; + onWillCollapse?: () => 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,7 +57,7 @@ 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} ); } @@ -63,9 +66,11 @@ export default class Accordion extends React.Component { // Make sure the children are mounted first before expanding the accordion if (!this.state.mountChildren) { this.setState({ mountChildren: true }, () => { + this.onWillExpand(); this.setState({ containerHeight: this.getContentHeight() }); }); } else { + this.onWillExpand(); this.setState({ containerHeight: this.getContentHeight() }); } } @@ -73,6 +78,7 @@ export default class Accordion extends React.Component { 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.props.onWillCollapse?.(); // Make sure new height has been applied // eslint-disable-next-line @typescript-eslint/no-unused-expressions this.containerRef.current?.offsetHeight; @@ -84,6 +90,12 @@ export default class Accordion extends React.Component { return (this.containerRef.current?.scrollHeight ?? 0) + 'px'; } + private onWillExpand() { + if (this.contentRef.current) { + this.props.onWillExpand?.(this.contentRef.current.offsetHeight); + } + } + private onTransitionEnd = () => { if (this.props.expanded) { // Height auto enables the container to grow if the content changes size diff --git a/gui/src/renderer/components/BridgeLocations.tsx b/gui/src/renderer/components/BridgeLocations.tsx index 7d464e092685..4f05aeb73d50 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?: (buttonDimensions: DOMRect, additionalHeight: number) => void; + onWillCollapse?: () => 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..78a9654ad9af 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?: (buttonDimensions: DOMRect, additionalHeight: number) => void; + onWillCollapse?: () => 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 buttonDimensions = buttonNode.getBoundingClientRect(); + this.props.onWillExpand?.(buttonDimensions, nextHeight); + } + }; } diff --git a/gui/src/renderer/components/CountryRow.tsx b/gui/src/renderer/components/CountryRow.tsx index 026a55343d41..6813a5b0a3a3 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?: (buttonDimensions: DOMRect, additionalHeight: number) => void; + onWillCollapse?: () => 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 buttonDimensions = buttonNode.getBoundingClientRect(); + this.props.onWillExpand?.(buttonDimensions, nextHeight); + } + }; } diff --git a/gui/src/renderer/components/CustomScrollbars.tsx b/gui/src/renderer/components/CustomScrollbars.tsx index fc91d7d23975..9c23d6b08a35 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,24 @@ export default class CustomScrollbars extends React.Component { } } + public scrollIntoView(elementDimensions: DOMRect) { + const scrollable = this.scrollableRef.current; + if (scrollable) { + const scrollableDimensions = scrollable.getBoundingClientRect(); + // The element position needs to be relative to the parent, not the document + elementDimensions.y -= scrollableDimensions.top; + const bottomOverflow = elementDimensions.bottom - scrollableDimensions.height; + + if (bottomOverflow > 0) { + scrollable.scrollBy({ + // Prevent the elements top from being scrolled out of the visible area + top: Math.min(bottomOverflow, elementDimensions.top), + behavior: 'smooth', + }); + } + } + } + public getScrollPosition(): [number, number] { const scroll = this.scrollableRef.current; if (scroll) { @@ -130,7 +149,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 +225,7 @@ export default class CustomScrollbars extends React.Component { }; private handleEnterTrack = () => { - this.stopAutoHide(); + this.autoHideScheduler.cancel(); this.setState({ isTrackHovered: true, showScrollIndicators: true, @@ -326,10 +345,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 +355,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 +363,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..0ab704b74799 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?: (buttonDimensions: DOMRect, additionalHeight: number) => void; + onWillCollapse?: () => 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..63fbb6afa8fe 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?: (buttonDimensions: DOMRect, additionalHeight: number) => void; + onWillCollapse?: () => 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} + onWillCollapse={this.props.onWillCollapse} {...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} + onWillCollapse={this.props.onWillCollapse} {...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..cf2290959142 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 heightAdditionRef = 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,40 @@ export default class SelectLocation extends Component { this.props.onSelectClosestToExit(); } }; + + private onWillExpand = (buttonDimensions: DOMRect, additionalHeight: number) => { + buttonDimensions.height += additionalHeight; + this.heightAdditionRef.current?.addHeight(additionalHeight); + this.scrollView.current?.scrollIntoView(buttonDimensions); + }; +} + +interface IContentPaddingProps { + children?: React.ReactNode; +} + +class HeightAddition extends Component { + ref = React.createRef(); + + public addHeight(additionalHeight: number) { + this.resetHeight(); + if (this.ref.current) { + this.minHeight = this.ref.current.offsetHeight + additionalHeight + 'px'; + } + } + + public resetHeight = () => { + 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; + } + } }