diff --git a/frontend/src/Components/Label.tsx b/frontend/src/Components/Label.tsx index 411cefddf..9ab360f42 100644 --- a/frontend/src/Components/Label.tsx +++ b/frontend/src/Components/Label.tsx @@ -1,11 +1,13 @@ import classNames from 'classnames'; import React, { ComponentProps, ReactNode } from 'react'; import { kinds, sizes } from 'Helpers/Props'; +import { Kind } from 'Helpers/Props/kinds'; +import { Size } from 'Helpers/Props/sizes'; import styles from './Label.css'; export interface LabelProps extends ComponentProps<'span'> { - kind?: Extract<(typeof kinds.all)[number], keyof typeof styles>; - size?: Extract<(typeof sizes.all)[number], keyof typeof styles>; + kind?: Extract; + size?: Extract; outline?: boolean; children: ReactNode; } diff --git a/frontend/src/Components/Link/Button.js b/frontend/src/Components/Link/Button.js deleted file mode 100644 index cbe4691d4..000000000 --- a/frontend/src/Components/Link/Button.js +++ /dev/null @@ -1,54 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { align, kinds, sizes } from 'Helpers/Props'; -import Link from './Link'; -import styles from './Button.css'; - -class Button extends Component { - - // - // Render - - render() { - const { - className, - buttonGroupPosition, - kind, - size, - children, - ...otherProps - } = this.props; - - return ( - - {children} - - ); - } - -} - -Button.propTypes = { - className: PropTypes.string.isRequired, - buttonGroupPosition: PropTypes.oneOf(align.all), - kind: PropTypes.oneOf(kinds.all), - size: PropTypes.oneOf(sizes.all), - children: PropTypes.node -}; - -Button.defaultProps = { - className: styles.button, - kind: kinds.DEFAULT, - size: sizes.MEDIUM -}; - -export default Button; diff --git a/frontend/src/Components/Link/Button.tsx b/frontend/src/Components/Link/Button.tsx new file mode 100644 index 000000000..cf2293f59 --- /dev/null +++ b/frontend/src/Components/Link/Button.tsx @@ -0,0 +1,37 @@ +import classNames from 'classnames'; +import React from 'react'; +import { align, kinds, sizes } from 'Helpers/Props'; +import { Kind } from 'Helpers/Props/kinds'; +import { Size } from 'Helpers/Props/sizes'; +import Link, { LinkProps } from './Link'; +import styles from './Button.css'; + +export interface ButtonProps extends Omit { + buttonGroupPosition?: Extract< + (typeof align.all)[number], + keyof typeof styles + >; + kind?: Extract; + size?: Extract; + children: Required; +} + +export default function Button({ + className = styles.button, + buttonGroupPosition, + kind = kinds.DEFAULT, + size = sizes.MEDIUM, + ...otherProps +}: ButtonProps) { + return ( + + ); +} diff --git a/frontend/src/Components/Loading/LoadingIndicator.js b/frontend/src/Components/Loading/LoadingIndicator.js deleted file mode 100644 index ffed05b1b..000000000 --- a/frontend/src/Components/Loading/LoadingIndicator.js +++ /dev/null @@ -1,51 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; -import styles from './LoadingIndicator.css'; - -function LoadingIndicator({ className, rippleClassName, size }) { - const sizeInPx = `${size}px`; - const width = sizeInPx; - const height = sizeInPx; - - return ( -
-
-
- -
- -
-
-
- ); -} - -LoadingIndicator.propTypes = { - className: PropTypes.string, - rippleClassName: PropTypes.string, - size: PropTypes.number -}; - -LoadingIndicator.defaultProps = { - className: styles.loading, - rippleClassName: styles.ripple, - size: 50 -}; - -export default LoadingIndicator; diff --git a/frontend/src/Components/Loading/LoadingIndicator.tsx b/frontend/src/Components/Loading/LoadingIndicator.tsx new file mode 100644 index 000000000..00ad803fa --- /dev/null +++ b/frontend/src/Components/Loading/LoadingIndicator.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import styles from './LoadingIndicator.css'; + +interface LoadingIndicatorProps { + className?: string; + rippleClassName?: string; + size?: number; +} + +function LoadingIndicator({ + className = styles.loading, + rippleClassName = styles.ripple, + size = 50, +}: LoadingIndicatorProps) { + const sizeInPx = `${size}px`; + const width = sizeInPx; + const height = sizeInPx; + + return ( +
+
+
+ +
+ +
+
+
+ ); +} + +export default LoadingIndicator; diff --git a/frontend/src/Components/Loading/LoadingMessage.js b/frontend/src/Components/Loading/LoadingMessage.tsx similarity index 60% rename from frontend/src/Components/Loading/LoadingMessage.js rename to frontend/src/Components/Loading/LoadingMessage.tsx index 0f2c2f882..bc2b48af5 100644 --- a/frontend/src/Components/Loading/LoadingMessage.js +++ b/frontend/src/Components/Loading/LoadingMessage.tsx @@ -4,25 +4,25 @@ import styles from './LoadingMessage.css'; const messages = [ 'Downloading more RAM', 'Now in Technicolor', - 'Previously on Lidarr...', + 'Previously on Sonarr...', 'Bleep Bloop.', 'Locating the required gigapixels to render...', 'Spinning up the hamster wheel...', - 'At least you\'re not on hold', + "At least you're not on hold", 'Hum something loud while others stare', 'Loading humorous message... Please Wait', - 'I could\'ve been faster in Python', - 'Don\'t forget to rewind your tracks', + "I could've been faster in Python", + "Don't forget to rewind your episodes", 'Congratulations! You are the 1000th visitor.', - 'HELP! I\'m being held hostage and forced to write these stupid lines!', + "HELP! I'm being held hostage and forced to write these stupid lines!", 'RE-calibrating the internet...', - 'I\'ll be here all week', - 'Don\'t forget to tip your waitress', + "I'll be here all week", + "Don't forget to tip your waitress", 'Apply directly to the forehead', - 'Loading Battlestation' + 'Loading Battlestation', ]; -let message = null; +let message: string | null = null; function LoadingMessage() { if (!message) { @@ -30,11 +30,7 @@ function LoadingMessage() { message = messages[index]; } - return ( -
- {message} -
- ); + return
{message}
; } export default LoadingMessage; diff --git a/frontend/src/Components/Markdown/InlineMarkdown.js b/frontend/src/Components/Markdown/InlineMarkdown.js deleted file mode 100644 index 993bb241e..000000000 --- a/frontend/src/Components/Markdown/InlineMarkdown.js +++ /dev/null @@ -1,74 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Link from 'Components/Link/Link'; - -class InlineMarkdown extends Component { - - // - // Render - - render() { - const { - className, - data, - blockClassName - } = this.props; - - // For now only replace links or code blocks (not both) - const markdownBlocks = []; - if (data) { - const linkRegex = RegExp(/\[(.+?)\]\((.+?)\)/g); - - let endIndex = 0; - let match = null; - - while ((match = linkRegex.exec(data)) !== null) { - if (match.index > endIndex) { - markdownBlocks.push(data.substr(endIndex, match.index - endIndex)); - } - - markdownBlocks.push({match[1]}); - endIndex = match.index + match[0].length; - } - - if (endIndex !== data.length && markdownBlocks.length > 0) { - markdownBlocks.push(data.substr(endIndex, data.length - endIndex)); - } - - const codeRegex = RegExp(/(?=`)`(?!`)[^`]*(?=`)`(?!`)/g); - - endIndex = 0; - match = null; - let matchedCode = false; - - while ((match = codeRegex.exec(data)) !== null) { - matchedCode = true; - - if (match.index > endIndex) { - markdownBlocks.push(data.substr(endIndex, match.index - endIndex)); - } - - markdownBlocks.push({match[0].substring(1, match[0].length - 1)}); - endIndex = match.index + match[0].length; - } - - if (endIndex !== data.length && markdownBlocks.length > 0 && matchedCode) { - markdownBlocks.push(data.substr(endIndex, data.length - endIndex)); - } - - if (markdownBlocks.length === 0) { - markdownBlocks.push(data); - } - } - - return {markdownBlocks}; - } -} - -InlineMarkdown.propTypes = { - className: PropTypes.string, - data: PropTypes.string, - blockClassName: PropTypes.string -}; - -export default InlineMarkdown; diff --git a/frontend/src/Components/Markdown/InlineMarkdown.tsx b/frontend/src/Components/Markdown/InlineMarkdown.tsx new file mode 100644 index 000000000..80e99336a --- /dev/null +++ b/frontend/src/Components/Markdown/InlineMarkdown.tsx @@ -0,0 +1,75 @@ +import React, { ReactElement } from 'react'; +import Link from 'Components/Link/Link'; + +interface InlineMarkdownProps { + className?: string; + data?: string; + blockClassName?: string; +} + +function InlineMarkdown(props: InlineMarkdownProps) { + const { className, data, blockClassName } = props; + + // For now only replace links or code blocks (not both) + const markdownBlocks: (ReactElement | string)[] = []; + + if (data) { + const linkRegex = RegExp(/\[(.+?)\]\((.+?)\)/g); + + let endIndex = 0; + let match = null; + + while ((match = linkRegex.exec(data)) !== null) { + if (match.index > endIndex) { + markdownBlocks.push(data.substr(endIndex, match.index - endIndex)); + } + + markdownBlocks.push( + + {match[1]} + + ); + endIndex = match.index + match[0].length; + } + + if (endIndex !== data.length && markdownBlocks.length > 0) { + markdownBlocks.push(data.substr(endIndex, data.length - endIndex)); + } + + const codeRegex = RegExp(/(?=`)`(?!`)[^`]*(?=`)`(?!`)/g); + + endIndex = 0; + match = null; + let matchedCode = false; + + while ((match = codeRegex.exec(data)) !== null) { + matchedCode = true; + + if (match.index > endIndex) { + markdownBlocks.push(data.substr(endIndex, match.index - endIndex)); + } + + markdownBlocks.push( + + {match[0].substring(1, match[0].length - 1)} + + ); + endIndex = match.index + match[0].length; + } + + if (endIndex !== data.length && markdownBlocks.length > 0 && matchedCode) { + markdownBlocks.push(data.substr(endIndex, data.length - endIndex)); + } + + if (markdownBlocks.length === 0) { + markdownBlocks.push(data); + } + } + + return {markdownBlocks}; +} + +export default InlineMarkdown; diff --git a/frontend/src/Components/MonitorToggleButton.js b/frontend/src/Components/MonitorToggleButton.js deleted file mode 100644 index ed6925a89..000000000 --- a/frontend/src/Components/MonitorToggleButton.js +++ /dev/null @@ -1,79 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; -import { icons } from 'Helpers/Props'; -import styles from './MonitorToggleButton.css'; - -function getTooltip(monitored, isDisabled) { - if (isDisabled) { - return 'Cannot toggle monitored state when artist is unmonitored'; - } - - if (monitored) { - return 'Monitored, click to unmonitor'; - } - - return 'Unmonitored, click to monitor'; -} - -class MonitorToggleButton extends Component { - - // - // Listeners - - onPress = (event) => { - const shiftKey = event.nativeEvent.shiftKey; - - this.props.onPress(!this.props.monitored, { shiftKey }); - }; - - // - // Render - - render() { - const { - className, - monitored, - isDisabled, - isSaving, - size, - ...otherProps - } = this.props; - - const iconName = monitored ? icons.MONITORED : icons.UNMONITORED; - - return ( - - ); - } -} - -MonitorToggleButton.propTypes = { - className: PropTypes.string.isRequired, - monitored: PropTypes.bool.isRequired, - size: PropTypes.number, - isDisabled: PropTypes.bool.isRequired, - isSaving: PropTypes.bool.isRequired, - onPress: PropTypes.func.isRequired -}; - -MonitorToggleButton.defaultProps = { - className: styles.toggleButton, - isDisabled: false, - isSaving: false -}; - -export default MonitorToggleButton; diff --git a/frontend/src/Components/MonitorToggleButton.tsx b/frontend/src/Components/MonitorToggleButton.tsx new file mode 100644 index 000000000..1c1fcbbeb --- /dev/null +++ b/frontend/src/Components/MonitorToggleButton.tsx @@ -0,0 +1,65 @@ +import classNames from 'classnames'; +import React, { SyntheticEvent, useCallback, useMemo } from 'react'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import { icons } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import styles from './MonitorToggleButton.css'; + +interface MonitorToggleButtonProps { + className?: string; + monitored: boolean; + size?: number; + isDisabled?: boolean; + isSaving?: boolean; + onPress: (value: boolean, options: { shiftKey: boolean }) => unknown; +} + +function MonitorToggleButton(props: MonitorToggleButtonProps) { + const { + className = styles.toggleButton, + monitored, + isDisabled = false, + isSaving = false, + size, + onPress, + ...otherProps + } = props; + + const iconName = monitored ? icons.MONITORED : icons.UNMONITORED; + + const title = useMemo(() => { + if (isDisabled) { + return translate('ToggleMonitoredSeriesUnmonitored'); + } + + if (monitored) { + return translate('ToggleMonitoredToUnmonitored'); + } + + return translate('ToggleUnmonitoredToMonitored'); + }, [monitored, isDisabled]); + + const handlePress = useCallback( + (event: SyntheticEvent) => { + const shiftKey = event.nativeEvent.shiftKey; + + onPress(!monitored, { shiftKey }); + }, + [monitored, onPress] + ); + + return ( + + ); +} + +export default MonitorToggleButton; diff --git a/frontend/src/Components/NotFound.js b/frontend/src/Components/NotFound.tsx similarity index 55% rename from frontend/src/Components/NotFound.js rename to frontend/src/Components/NotFound.tsx index 3544b50a6..a61c0631f 100644 --- a/frontend/src/Components/NotFound.js +++ b/frontend/src/Components/NotFound.tsx @@ -1,16 +1,19 @@ -import PropTypes from 'prop-types'; import React from 'react'; import PageContent from 'Components/Page/PageContent'; import translate from 'Utilities/String/translate'; import styles from './NotFound.css'; -function NotFound({ message }) { +interface NotFoundProps { + message?: string; +} + +function NotFound(props: NotFoundProps) { + const { message = translate('DefaultNotFoundMessage') } = props; + return ( - +
-
- {message} -
+
{message}
[0]; + target?: Parameters[1]; +} + +const defaultTarget = document.getElementById('portal-root'); + +function Portal(props: PortalProps) { + const { children, target = defaultTarget } = props; + + if (!target) { + return null; + } + + return ReactDOM.createPortal(children, target); +} + +export default Portal; diff --git a/frontend/src/Components/Router/Switch.js b/frontend/src/Components/Router/Switch.js deleted file mode 100644 index 6479d5291..000000000 --- a/frontend/src/Components/Router/Switch.js +++ /dev/null @@ -1,44 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { Switch as RouterSwitch } from 'react-router-dom'; -import { map } from 'Helpers/elementChildren'; -import getPathWithUrlBase from 'Utilities/getPathWithUrlBase'; - -class Switch extends Component { - - // - // Render - - render() { - const { - children - } = this.props; - - return ( - - { - map(children, (child) => { - const { - path: childPath, - addUrlBase = true - } = child.props; - - if (!childPath) { - return child; - } - - const path = addUrlBase ? getPathWithUrlBase(childPath) : childPath; - - return React.cloneElement(child, { path }); - }) - } - - ); - } -} - -Switch.propTypes = { - children: PropTypes.node.isRequired -}; - -export default Switch; diff --git a/frontend/src/Components/Router/Switch.tsx b/frontend/src/Components/Router/Switch.tsx new file mode 100644 index 000000000..032471681 --- /dev/null +++ b/frontend/src/Components/Router/Switch.tsx @@ -0,0 +1,38 @@ +import React, { Children, ReactElement, ReactNode } from 'react'; +import { Switch as RouterSwitch } from 'react-router-dom'; +import getPathWithUrlBase from 'Utilities/getPathWithUrlBase'; + +interface ExtendedRoute { + path: string; + addUrlBase?: boolean; +} + +interface SwitchProps { + children: ReactNode; +} + +function Switch({ children }: SwitchProps) { + return ( + + {Children.map(children, (child) => { + if (!React.isValidElement(child)) { + return child; + } + + const elementChild: ReactElement = child; + + const { path: childPath, addUrlBase = true } = elementChild.props; + + if (!childPath) { + return child; + } + + const path = addUrlBase ? getPathWithUrlBase(childPath) : childPath; + + return React.cloneElement(child, { path }); + })} + + ); +} + +export default Switch; diff --git a/frontend/src/Components/Scroller/OverlayScroller.js b/frontend/src/Components/Scroller/OverlayScroller.js deleted file mode 100644 index e590c42b2..000000000 --- a/frontend/src/Components/Scroller/OverlayScroller.js +++ /dev/null @@ -1,179 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { Scrollbars } from 'react-custom-scrollbars-2'; -import { scrollDirections } from 'Helpers/Props'; -import styles from './OverlayScroller.css'; - -const SCROLLBAR_SIZE = 10; - -class OverlayScroller extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this._scroller = null; - this._isScrolling = false; - } - - componentDidUpdate(prevProps) { - const { - scrollTop - } = this.props; - - if ( - !this._isScrolling && - scrollTop != null && - scrollTop !== prevProps.scrollTop - ) { - this._scroller.scrollTop(scrollTop); - } - } - - // - // Control - - _setScrollRef = (ref) => { - this._scroller = ref; - - if (ref) { - this.props.registerScroller(ref.view); - } - }; - - _renderThumb = (props) => { - return ( -
- ); - }; - - _renderTrackHorizontal = ({ style, props }) => { - const finalStyle = { - ...style, - right: 2, - bottom: 2, - left: 2, - borderRadius: 3, - height: SCROLLBAR_SIZE - }; - - return ( -
- ); - }; - - _renderTrackVertical = ({ style, props }) => { - const finalStyle = { - ...style, - right: 2, - bottom: 2, - top: 2, - borderRadius: 3, - width: SCROLLBAR_SIZE - }; - - return ( -
- ); - }; - - _renderView = (props) => { - return ( -
- ); - }; - - // - // Listers - - onScrollStart = () => { - this._isScrolling = true; - }; - - onScrollStop = () => { - this._isScrolling = false; - }; - - onScroll = (event) => { - const { - scrollTop, - scrollLeft - } = event.currentTarget; - - this._isScrolling = true; - const onScroll = this.props.onScroll; - - if (onScroll) { - onScroll({ scrollTop, scrollLeft }); - } - }; - - // - // Render - - render() { - const { - autoHide, - autoScroll, - children - } = this.props; - - return ( - - {children} - - ); - } - -} - -OverlayScroller.propTypes = { - className: PropTypes.string, - trackClassName: PropTypes.string, - scrollTop: PropTypes.number, - scrollDirection: PropTypes.oneOf([scrollDirections.NONE, scrollDirections.HORIZONTAL, scrollDirections.VERTICAL]).isRequired, - autoHide: PropTypes.bool.isRequired, - autoScroll: PropTypes.bool.isRequired, - children: PropTypes.node, - onScroll: PropTypes.func, - registerScroller: PropTypes.func -}; - -OverlayScroller.defaultProps = { - className: styles.scroller, - trackClassName: styles.thumb, - scrollDirection: scrollDirections.VERTICAL, - autoHide: false, - autoScroll: true, - registerScroller: () => { /* no-op */ } -}; - -export default OverlayScroller; diff --git a/frontend/src/Components/Scroller/OverlayScroller.tsx b/frontend/src/Components/Scroller/OverlayScroller.tsx new file mode 100644 index 000000000..b242642e8 --- /dev/null +++ b/frontend/src/Components/Scroller/OverlayScroller.tsx @@ -0,0 +1,127 @@ +import React, { ComponentPropsWithoutRef, useCallback, useRef } from 'react'; +import { Scrollbars } from 'react-custom-scrollbars-2'; +import { ScrollDirection } from 'Helpers/Props/scrollDirections'; +import { OnScroll } from './Scroller'; +import styles from './OverlayScroller.css'; + +const SCROLLBAR_SIZE = 10; + +interface OverlayScrollerProps { + className?: string; + trackClassName?: string; + scrollTop?: number; + scrollDirection: ScrollDirection; + autoHide: boolean; + autoScroll: boolean; + children?: React.ReactNode; + onScroll?: (payload: OnScroll) => void; +} + +interface ScrollbarTrackProps { + style: React.CSSProperties; + props: ComponentPropsWithoutRef<'div'>; +} + +function OverlayScroller(props: OverlayScrollerProps) { + const { + autoHide = false, + autoScroll = true, + className = styles.scroller, + trackClassName = styles.thumb, + children, + onScroll, + } = props; + const scrollBarRef = useRef(null); + const isScrolling = useRef(false); + + const handleScrollStart = useCallback(() => { + isScrolling.current = true; + }, []); + const handleScrollStop = useCallback(() => { + isScrolling.current = false; + }, []); + + const handleScroll = useCallback(() => { + if (!scrollBarRef.current) { + return; + } + + const { scrollTop, scrollLeft } = scrollBarRef.current.getValues(); + isScrolling.current = true; + + if (onScroll) { + onScroll({ scrollTop, scrollLeft }); + } + }, [onScroll]); + + const renderThumb = useCallback( + (props: ComponentPropsWithoutRef<'div'>) => { + return
; + }, + [trackClassName] + ); + + const renderTrackHorizontal = useCallback( + ({ style, props: trackProps }: ScrollbarTrackProps) => { + const finalStyle = { + ...style, + right: 2, + bottom: 2, + left: 2, + borderRadius: 3, + height: SCROLLBAR_SIZE, + }; + + return ( +
+ ); + }, + [] + ); + + const renderTrackVertical = useCallback( + ({ style, props: trackProps }: ScrollbarTrackProps) => { + const finalStyle = { + ...style, + right: 2, + bottom: 2, + top: 2, + borderRadius: 3, + width: SCROLLBAR_SIZE, + }; + + return ( +
+ ); + }, + [] + ); + + const renderView = useCallback( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (props: any) => { + return
; + }, + [className] + ); + + return ( + + {children} + + ); +} + +export default OverlayScroller; diff --git a/frontend/src/Components/SpinnerIcon.js b/frontend/src/Components/SpinnerIcon.js deleted file mode 100644 index 5ae03ee66..000000000 --- a/frontend/src/Components/SpinnerIcon.js +++ /dev/null @@ -1,34 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { icons } from 'Helpers/Props'; -import Icon from './Icon'; - -function SpinnerIcon(props) { - const { - name, - spinningName, - isSpinning, - ...otherProps - } = props; - - return ( - - ); -} - -SpinnerIcon.propTypes = { - className: PropTypes.string, - name: PropTypes.object.isRequired, - spinningName: PropTypes.object.isRequired, - isSpinning: PropTypes.bool.isRequired -}; - -SpinnerIcon.defaultProps = { - spinningName: icons.SPINNER -}; - -export default SpinnerIcon; diff --git a/frontend/src/Components/SpinnerIcon.tsx b/frontend/src/Components/SpinnerIcon.tsx new file mode 100644 index 000000000..d9124d692 --- /dev/null +++ b/frontend/src/Components/SpinnerIcon.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { icons } from 'Helpers/Props'; +import Icon, { IconProps } from './Icon'; + +export interface SpinnerIconProps extends IconProps { + spinningName?: IconProps['name']; + isSpinning: Required; +} + +export default function SpinnerIcon({ + name, + spinningName = icons.SPINNER, + isSpinning, + ...otherProps +}: SpinnerIconProps) { + return ( + + ); +} diff --git a/frontend/src/Components/Tooltip/Popover.js b/frontend/src/Components/Tooltip/Popover.js deleted file mode 100644 index 1fe92fcbf..000000000 --- a/frontend/src/Components/Tooltip/Popover.js +++ /dev/null @@ -1,43 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { tooltipPositions } from 'Helpers/Props'; -import Tooltip from './Tooltip'; -import styles from './Popover.css'; - -function Popover(props) { - const { - title, - body, - ...otherProps - } = props; - - return ( - -
- {title} -
- -
- {body} -
-
- } - /> - ); -} - -Popover.propTypes = { - className: PropTypes.string, - bodyClassName: PropTypes.string, - anchor: PropTypes.node.isRequired, - title: PropTypes.string.isRequired, - body: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired, - position: PropTypes.oneOf(tooltipPositions.all), - canFlip: PropTypes.bool -}; - -export default Popover; diff --git a/frontend/src/Components/Tooltip/Popover.tsx b/frontend/src/Components/Tooltip/Popover.tsx new file mode 100644 index 000000000..4c6781343 --- /dev/null +++ b/frontend/src/Components/Tooltip/Popover.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import Tooltip, { TooltipProps } from './Tooltip'; +import styles from './Popover.css'; + +interface PopoverProps extends Omit { + title: string; + body: React.ReactNode; +} + +function Popover({ title, body, ...otherProps }: PopoverProps) { + return ( + +
{title}
+ +
{body}
+
+ } + /> + ); +} + +export default Popover; diff --git a/frontend/src/Components/Tooltip/Tooltip.js b/frontend/src/Components/Tooltip/Tooltip.js deleted file mode 100644 index 1499e7451..000000000 --- a/frontend/src/Components/Tooltip/Tooltip.js +++ /dev/null @@ -1,235 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { Manager, Popper, Reference } from 'react-popper'; -import Portal from 'Components/Portal'; -import { kinds, tooltipPositions } from 'Helpers/Props'; -import dimensions from 'Styles/Variables/dimensions'; -import { isMobile as isMobileUtil } from 'Utilities/browser'; -import styles from './Tooltip.css'; - -let maxWidth = null; - -function getMaxWidth() { - const windowWidth = window.innerWidth; - - if (windowWidth >= parseInt(dimensions.breakpointLarge)) { - maxWidth = 800; - } else if (windowWidth >= parseInt(dimensions.breakpointMedium)) { - maxWidth = 650; - } else if (windowWidth >= parseInt(dimensions.breakpointSmall)) { - maxWidth = 500; - } else { - maxWidth = 450; - } - - return maxWidth; -} - -class Tooltip extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this._scheduleUpdate = null; - this._closeTimeout = null; - this._maxWidth = maxWidth || getMaxWidth(); - - this.state = { - isOpen: false - }; - } - - componentDidUpdate() { - if (this._scheduleUpdate && this.state.isOpen) { - this._scheduleUpdate(); - } - } - - componentWillUnmount() { - if (this._closeTimeout) { - this._closeTimeout = clearTimeout(this._closeTimeout); - } - } - - // - // Control - - computeMaxSize = (data) => { - const { - top, - right, - bottom, - left - } = data.offsets.reference; - - const windowWidth = window.innerWidth; - const windowHeight = window.innerHeight; - - if ((/^top/).test(data.placement)) { - data.styles.maxHeight = top - 20; - } else if ((/^bottom/).test(data.placement)) { - data.styles.maxHeight = windowHeight - bottom - 20; - } else if ((/^right/).test(data.placement)) { - data.styles.maxWidth = Math.min(this._maxWidth, windowWidth - right - 20); - data.styles.maxHeight = top - 20; - } else { - data.styles.maxWidth = Math.min(this._maxWidth, left - 20); - data.styles.maxHeight = top - 20; - } - - return data; - }; - - // - // Listeners - - onMeasure = ({ width }) => { - this.setState({ width }); - }; - - onClick = () => { - if (isMobileUtil()) { - this.setState({ isOpen: !this.state.isOpen }); - } - }; - - onMouseEnter = () => { - if (this._closeTimeout) { - this._closeTimeout = clearTimeout(this._closeTimeout); - } - - this.setState({ isOpen: true }); - }; - - onMouseLeave = () => { - this._closeTimeout = setTimeout(() => { - this.setState({ isOpen: false }); - }, 100); - }; - - // - // Render - - render() { - const { - className, - bodyClassName, - anchor, - tooltip, - kind, - position, - canFlip - } = this.props; - - return ( - - - {({ ref }) => ( - - {anchor} - - )} - - - - - {({ ref, style, placement, arrowProps, scheduleUpdate }) => { - this._scheduleUpdate = scheduleUpdate; - - const popperPlacement = placement ? placement.split('-')[0] : position; - const vertical = popperPlacement === 'top' || popperPlacement === 'bottom'; - - return ( -
-
- { - this.state.isOpen ? -
-
- {tooltip} -
-
: - null - } -
- ); - }} - - - - ); - } -} - -Tooltip.propTypes = { - className: PropTypes.string, - bodyClassName: PropTypes.string.isRequired, - anchor: PropTypes.node.isRequired, - tooltip: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired, - kind: PropTypes.oneOf([kinds.DEFAULT, kinds.INVERSE]), - position: PropTypes.oneOf(tooltipPositions.all), - canFlip: PropTypes.bool.isRequired -}; - -Tooltip.defaultProps = { - bodyClassName: styles.body, - kind: kinds.DEFAULT, - position: tooltipPositions.TOP, - canFlip: false -}; - -export default Tooltip; diff --git a/frontend/src/Components/Tooltip/Tooltip.tsx b/frontend/src/Components/Tooltip/Tooltip.tsx new file mode 100644 index 000000000..35cce5738 --- /dev/null +++ b/frontend/src/Components/Tooltip/Tooltip.tsx @@ -0,0 +1,216 @@ +import classNames from 'classnames'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { Manager, Popper, Reference } from 'react-popper'; +import Portal from 'Components/Portal'; +import { kinds, tooltipPositions } from 'Helpers/Props'; +import { Kind } from 'Helpers/Props/kinds'; +import dimensions from 'Styles/Variables/dimensions'; +import { isMobile as isMobileUtil } from 'Utilities/browser'; +import styles from './Tooltip.css'; + +export interface TooltipProps { + className?: string; + bodyClassName?: string; + anchor: React.ReactNode; + tooltip: string | React.ReactNode; + kind?: Extract; + position?: (typeof tooltipPositions.all)[number]; + canFlip?: boolean; +} +function Tooltip(props: TooltipProps) { + const { + className, + bodyClassName = styles.body, + anchor, + tooltip, + kind = kinds.DEFAULT, + position = tooltipPositions.TOP, + canFlip = false, + } = props; + + const closeTimeout = useRef(0); + const updater = useRef<(() => void) | null>(null); + const [isOpen, setIsOpen] = useState(false); + + const handleClick = useCallback(() => { + if (!isMobileUtil()) { + return; + } + + setIsOpen((isOpen) => { + return !isOpen; + }); + }, [setIsOpen]); + + const handleMouseEnter = useCallback(() => { + // Mobile will fire mouse enter and click events rapidly, + // this causes the tooltip not to open on the first press. + // Ignore the mouse enter event on mobile. + if (isMobileUtil()) { + return; + } + + if (closeTimeout.current) { + window.clearTimeout(closeTimeout.current); + } + + setIsOpen(true); + }, [setIsOpen]); + + const handleMouseLeave = useCallback(() => { + // Still listen for mouse leave on mobile to allow clicks outside to close the tooltip. + + setTimeout(() => { + setIsOpen(false); + }, 100); + }, [setIsOpen]); + + const maxWidth = useMemo(() => { + const windowWidth = window.innerWidth; + + if (windowWidth >= parseInt(dimensions.breakpointLarge)) { + return 800; + } else if (windowWidth >= parseInt(dimensions.breakpointMedium)) { + return 650; + } else if (windowWidth >= parseInt(dimensions.breakpointSmall)) { + return 500; + } + + return 450; + }, []); + + const computeMaxSize = useCallback( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (data: any) => { + const { top, right, bottom, left } = data.offsets.reference; + + const windowWidth = window.innerWidth; + const windowHeight = window.innerHeight; + + if (/^top/.test(data.placement)) { + data.styles.maxHeight = top - 20; + } else if (/^bottom/.test(data.placement)) { + data.styles.maxHeight = windowHeight - bottom - 20; + } else if (/^right/.test(data.placement)) { + data.styles.maxWidth = Math.min(maxWidth, windowWidth - right - 20); + data.styles.maxHeight = top - 20; + } else { + data.styles.maxWidth = Math.min(maxWidth, left - 20); + data.styles.maxHeight = top - 20; + } + + return data; + }, + [maxWidth] + ); + + useEffect(() => { + const currentTimeout = closeTimeout.current; + + if (updater.current && isOpen) { + updater.current(); + } + + return () => { + if (currentTimeout) { + window.clearTimeout(currentTimeout); + } + }; + }); + + return ( + + + {({ ref }) => ( + + {anchor} + + )} + + + + + {({ ref, style, placement, arrowProps, scheduleUpdate }) => { + updater.current = scheduleUpdate; + + const popperPlacement = placement + ? placement.split('-')[0] + : position; + const vertical = + popperPlacement === 'top' || popperPlacement === 'bottom'; + + return ( +
+
+ {isOpen ? ( +
+
{tooltip}
+
+ ) : null} +
+ ); + }} + + + + ); +} + +export default Tooltip; diff --git a/frontend/src/Helpers/Props/align.js b/frontend/src/Helpers/Props/align.ts similarity index 100% rename from frontend/src/Helpers/Props/align.js rename to frontend/src/Helpers/Props/align.ts