mirror of
https://github.com/Lidarr/Lidarr
synced 2025-12-06 08:25:54 +01:00
More components converted to typescript
This commit is contained in:
parent
e127c3b710
commit
a444769fe9
24 changed files with 682 additions and 841 deletions
|
|
@ -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<Kind, keyof typeof styles>;
|
||||
size?: Extract<Size, keyof typeof styles>;
|
||||
outline?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Link
|
||||
className={classNames(
|
||||
className,
|
||||
styles[kind],
|
||||
styles[size],
|
||||
buttonGroupPosition && styles[buttonGroupPosition]
|
||||
)}
|
||||
{...otherProps}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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;
|
||||
37
frontend/src/Components/Link/Button.tsx
Normal file
37
frontend/src/Components/Link/Button.tsx
Normal file
|
|
@ -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<LinkProps, 'children' | 'size'> {
|
||||
buttonGroupPosition?: Extract<
|
||||
(typeof align.all)[number],
|
||||
keyof typeof styles
|
||||
>;
|
||||
kind?: Extract<Kind, keyof typeof styles>;
|
||||
size?: Extract<Size, keyof typeof styles>;
|
||||
children: Required<LinkProps['children']>;
|
||||
}
|
||||
|
||||
export default function Button({
|
||||
className = styles.button,
|
||||
buttonGroupPosition,
|
||||
kind = kinds.DEFAULT,
|
||||
size = sizes.MEDIUM,
|
||||
...otherProps
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<Link
|
||||
className={classNames(
|
||||
className,
|
||||
styles[kind],
|
||||
styles[size],
|
||||
buttonGroupPosition && styles[buttonGroupPosition]
|
||||
)}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div
|
||||
className={className}
|
||||
style={{ height }}
|
||||
>
|
||||
<div
|
||||
className={classNames(styles.rippleContainer, 'followingBalls')}
|
||||
style={{ width, height }}
|
||||
>
|
||||
<div
|
||||
className={rippleClassName}
|
||||
style={{ width, height }}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={rippleClassName}
|
||||
style={{ width, height }}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={rippleClassName}
|
||||
style={{ width, height }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
LoadingIndicator.propTypes = {
|
||||
className: PropTypes.string,
|
||||
rippleClassName: PropTypes.string,
|
||||
size: PropTypes.number
|
||||
};
|
||||
|
||||
LoadingIndicator.defaultProps = {
|
||||
className: styles.loading,
|
||||
rippleClassName: styles.ripple,
|
||||
size: 50
|
||||
};
|
||||
|
||||
export default LoadingIndicator;
|
||||
32
frontend/src/Components/Loading/LoadingIndicator.tsx
Normal file
32
frontend/src/Components/Loading/LoadingIndicator.tsx
Normal file
|
|
@ -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 (
|
||||
<div className={className} style={{ height }}>
|
||||
<div className={styles.rippleContainer} style={{ width, height }}>
|
||||
<div className={rippleClassName} style={{ width, height }} />
|
||||
|
||||
<div className={rippleClassName} style={{ width, height }} />
|
||||
|
||||
<div className={rippleClassName} style={{ width, height }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoadingIndicator;
|
||||
|
|
@ -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 (
|
||||
<div className={styles.loadingMessage}>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
return <div className={styles.loadingMessage}>{message}</div>;
|
||||
}
|
||||
|
||||
export default LoadingMessage;
|
||||
|
|
@ -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(<Link key={match.index} to={match[2]}>{match[1]}</Link>);
|
||||
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(<code key={`code-${match.index}`} className={blockClassName ?? null}>{match[0].substring(1, match[0].length - 1)}</code>);
|
||||
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 <span className={className}>{markdownBlocks}</span>;
|
||||
}
|
||||
}
|
||||
|
||||
InlineMarkdown.propTypes = {
|
||||
className: PropTypes.string,
|
||||
data: PropTypes.string,
|
||||
blockClassName: PropTypes.string
|
||||
};
|
||||
|
||||
export default InlineMarkdown;
|
||||
75
frontend/src/Components/Markdown/InlineMarkdown.tsx
Normal file
75
frontend/src/Components/Markdown/InlineMarkdown.tsx
Normal file
|
|
@ -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(
|
||||
<Link key={match.index} to={match[2]}>
|
||||
{match[1]}
|
||||
</Link>
|
||||
);
|
||||
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(
|
||||
<code
|
||||
key={`code-${match.index}`}
|
||||
className={blockClassName ?? undefined}
|
||||
>
|
||||
{match[0].substring(1, match[0].length - 1)}
|
||||
</code>
|
||||
);
|
||||
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 <span className={className}>{markdownBlocks}</span>;
|
||||
}
|
||||
|
||||
export default InlineMarkdown;
|
||||
|
|
@ -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 (
|
||||
<SpinnerIconButton
|
||||
className={classNames(
|
||||
className,
|
||||
isDisabled && styles.isDisabled
|
||||
)}
|
||||
name={iconName}
|
||||
size={size}
|
||||
title={getTooltip(monitored, isDisabled)}
|
||||
isDisabled={isDisabled}
|
||||
isSpinning={isSaving}
|
||||
{...otherProps}
|
||||
onPress={this.onPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
65
frontend/src/Components/MonitorToggleButton.tsx
Normal file
65
frontend/src/Components/MonitorToggleButton.tsx
Normal file
|
|
@ -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<HTMLLinkElement, MouseEvent>) => {
|
||||
const shiftKey = event.nativeEvent.shiftKey;
|
||||
|
||||
onPress(!monitored, { shiftKey });
|
||||
},
|
||||
[monitored, onPress]
|
||||
);
|
||||
|
||||
return (
|
||||
<SpinnerIconButton
|
||||
className={classNames(className, isDisabled && styles.isDisabled)}
|
||||
name={iconName}
|
||||
size={size}
|
||||
title={title}
|
||||
isDisabled={isDisabled}
|
||||
isSpinning={isSaving}
|
||||
{...otherProps}
|
||||
onPress={handlePress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default MonitorToggleButton;
|
||||
|
|
@ -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 (
|
||||
<PageContent title={translate('MIA')}>
|
||||
<PageContent title="MIA">
|
||||
<div className={styles.container}>
|
||||
<div className={styles.message}>
|
||||
{message}
|
||||
</div>
|
||||
<div className={styles.message}>{message}</div>
|
||||
|
||||
<img
|
||||
className={styles.image}
|
||||
|
|
@ -21,12 +24,4 @@ function NotFound({ message }) {
|
|||
);
|
||||
}
|
||||
|
||||
NotFound.propTypes = {
|
||||
message: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
NotFound.defaultProps = {
|
||||
message: 'You must be lost, nothing to see here.'
|
||||
};
|
||||
|
||||
export default NotFound;
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
function Portal(props) {
|
||||
const { children, target } = props;
|
||||
return ReactDOM.createPortal(children, target);
|
||||
}
|
||||
|
||||
Portal.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
target: PropTypes.object.isRequired
|
||||
};
|
||||
|
||||
Portal.defaultProps = {
|
||||
target: document.getElementById('portal-root')
|
||||
};
|
||||
|
||||
export default Portal;
|
||||
20
frontend/src/Components/Portal.tsx
Normal file
20
frontend/src/Components/Portal.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import ReactDOM from 'react-dom';
|
||||
|
||||
interface PortalProps {
|
||||
children: Parameters<typeof ReactDOM.createPortal>[0];
|
||||
target?: Parameters<typeof ReactDOM.createPortal>[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;
|
||||
|
|
@ -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 (
|
||||
<RouterSwitch>
|
||||
{
|
||||
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 });
|
||||
})
|
||||
}
|
||||
</RouterSwitch>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Switch.propTypes = {
|
||||
children: PropTypes.node.isRequired
|
||||
};
|
||||
|
||||
export default Switch;
|
||||
38
frontend/src/Components/Router/Switch.tsx
Normal file
38
frontend/src/Components/Router/Switch.tsx
Normal file
|
|
@ -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 (
|
||||
<RouterSwitch>
|
||||
{Children.map(children, (child) => {
|
||||
if (!React.isValidElement<ExtendedRoute>(child)) {
|
||||
return child;
|
||||
}
|
||||
|
||||
const elementChild: ReactElement<ExtendedRoute> = child;
|
||||
|
||||
const { path: childPath, addUrlBase = true } = elementChild.props;
|
||||
|
||||
if (!childPath) {
|
||||
return child;
|
||||
}
|
||||
|
||||
const path = addUrlBase ? getPathWithUrlBase(childPath) : childPath;
|
||||
|
||||
return React.cloneElement(child, { path });
|
||||
})}
|
||||
</RouterSwitch>
|
||||
);
|
||||
}
|
||||
|
||||
export default Switch;
|
||||
|
|
@ -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 (
|
||||
<div
|
||||
className={this.props.trackClassName}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
_renderTrackHorizontal = ({ style, props }) => {
|
||||
const finalStyle = {
|
||||
...style,
|
||||
right: 2,
|
||||
bottom: 2,
|
||||
left: 2,
|
||||
borderRadius: 3,
|
||||
height: SCROLLBAR_SIZE
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.track}
|
||||
style={finalStyle}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
_renderTrackVertical = ({ style, props }) => {
|
||||
const finalStyle = {
|
||||
...style,
|
||||
right: 2,
|
||||
bottom: 2,
|
||||
top: 2,
|
||||
borderRadius: 3,
|
||||
width: SCROLLBAR_SIZE
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.track}
|
||||
style={finalStyle}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
_renderView = (props) => {
|
||||
return (
|
||||
<div
|
||||
className={this.props.className}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
//
|
||||
// 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 (
|
||||
<Scrollbars
|
||||
ref={this._setScrollRef}
|
||||
autoHide={autoHide}
|
||||
hideTracksWhenNotNeeded={autoScroll}
|
||||
renderTrackHorizontal={this._renderTrackHorizontal}
|
||||
renderTrackVertical={this._renderTrackVertical}
|
||||
renderThumbHorizontal={this._renderThumb}
|
||||
renderThumbVertical={this._renderThumb}
|
||||
renderView={this._renderView}
|
||||
onScrollStart={this.onScrollStart}
|
||||
onScrollStop={this.onScrollStop}
|
||||
onScroll={this.onScroll}
|
||||
>
|
||||
{children}
|
||||
</Scrollbars>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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;
|
||||
127
frontend/src/Components/Scroller/OverlayScroller.tsx
Normal file
127
frontend/src/Components/Scroller/OverlayScroller.tsx
Normal file
|
|
@ -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<Scrollbars>(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 <div className={trackClassName} {...props} />;
|
||||
},
|
||||
[trackClassName]
|
||||
);
|
||||
|
||||
const renderTrackHorizontal = useCallback(
|
||||
({ style, props: trackProps }: ScrollbarTrackProps) => {
|
||||
const finalStyle = {
|
||||
...style,
|
||||
right: 2,
|
||||
bottom: 2,
|
||||
left: 2,
|
||||
borderRadius: 3,
|
||||
height: SCROLLBAR_SIZE,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.track} style={finalStyle} {...trackProps} />
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const renderTrackVertical = useCallback(
|
||||
({ style, props: trackProps }: ScrollbarTrackProps) => {
|
||||
const finalStyle = {
|
||||
...style,
|
||||
right: 2,
|
||||
bottom: 2,
|
||||
top: 2,
|
||||
borderRadius: 3,
|
||||
width: SCROLLBAR_SIZE,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.track} style={finalStyle} {...trackProps} />
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const renderView = useCallback(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(props: any) => {
|
||||
return <div className={className} {...props} />;
|
||||
},
|
||||
[className]
|
||||
);
|
||||
|
||||
return (
|
||||
<Scrollbars
|
||||
ref={scrollBarRef}
|
||||
autoHide={autoHide}
|
||||
hideTracksWhenNotNeeded={autoScroll}
|
||||
renderTrackHorizontal={renderTrackHorizontal}
|
||||
renderTrackVertical={renderTrackVertical}
|
||||
renderThumbHorizontal={renderThumb}
|
||||
renderThumbVertical={renderThumb}
|
||||
renderView={renderView}
|
||||
onScrollStart={handleScrollStart}
|
||||
onScrollStop={handleScrollStop}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{children}
|
||||
</Scrollbars>
|
||||
);
|
||||
}
|
||||
|
||||
export default OverlayScroller;
|
||||
|
|
@ -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 (
|
||||
<Icon
|
||||
name={isSpinning ? (spinningName || name) : name}
|
||||
isSpinning={isSpinning}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
23
frontend/src/Components/SpinnerIcon.tsx
Normal file
23
frontend/src/Components/SpinnerIcon.tsx
Normal file
|
|
@ -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<IconProps['isSpinning']>;
|
||||
}
|
||||
|
||||
export default function SpinnerIcon({
|
||||
name,
|
||||
spinningName = icons.SPINNER,
|
||||
isSpinning,
|
||||
...otherProps
|
||||
}: SpinnerIconProps) {
|
||||
return (
|
||||
<Icon
|
||||
name={(isSpinning && spinningName) || name}
|
||||
isSpinning={isSpinning}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<Tooltip
|
||||
{...otherProps}
|
||||
bodyClassName={styles.tooltipBody}
|
||||
tooltip={
|
||||
<div>
|
||||
<div className={styles.title}>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
<div className={styles.body}>
|
||||
{body}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
26
frontend/src/Components/Tooltip/Popover.tsx
Normal file
26
frontend/src/Components/Tooltip/Popover.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import React from 'react';
|
||||
import Tooltip, { TooltipProps } from './Tooltip';
|
||||
import styles from './Popover.css';
|
||||
|
||||
interface PopoverProps extends Omit<TooltipProps, 'tooltip' | 'bodyClassName'> {
|
||||
title: string;
|
||||
body: React.ReactNode;
|
||||
}
|
||||
|
||||
function Popover({ title, body, ...otherProps }: PopoverProps) {
|
||||
return (
|
||||
<Tooltip
|
||||
{...otherProps}
|
||||
bodyClassName={styles.tooltipBody}
|
||||
tooltip={
|
||||
<div>
|
||||
<div className={styles.title}>{title}</div>
|
||||
|
||||
<div className={styles.body}>{body}</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default Popover;
|
||||
|
|
@ -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 (
|
||||
<Manager>
|
||||
<Reference>
|
||||
{({ ref }) => (
|
||||
<span
|
||||
ref={ref}
|
||||
className={className}
|
||||
onClick={this.onClick}
|
||||
onMouseEnter={this.onMouseEnter}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
>
|
||||
{anchor}
|
||||
</span>
|
||||
)}
|
||||
</Reference>
|
||||
|
||||
<Portal>
|
||||
<Popper
|
||||
placement={position}
|
||||
// Disable events to improve performance when many tooltips
|
||||
// are shown (Quality Definitions for example).
|
||||
eventsEnabled={false}
|
||||
modifiers={{
|
||||
computeMaxHeight: {
|
||||
order: 851,
|
||||
enabled: true,
|
||||
fn: this.computeMaxSize
|
||||
},
|
||||
preventOverflow: {
|
||||
// Fixes positioning for tooltips in the queue
|
||||
// and likely others.
|
||||
escapeWithReference: false
|
||||
},
|
||||
flip: {
|
||||
enabled: canFlip
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ ref, style, placement, arrowProps, scheduleUpdate }) => {
|
||||
this._scheduleUpdate = scheduleUpdate;
|
||||
|
||||
const popperPlacement = placement ? placement.split('-')[0] : position;
|
||||
const vertical = popperPlacement === 'top' || popperPlacement === 'bottom';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
styles.tooltipContainer,
|
||||
vertical ? styles.verticalContainer : styles.horizontalContainer
|
||||
)}
|
||||
style={style}
|
||||
onMouseEnter={this.onMouseEnter}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
>
|
||||
<div
|
||||
className={this.state.isOpen ? classNames(
|
||||
styles.arrow,
|
||||
styles[kind],
|
||||
styles[popperPlacement]
|
||||
) : styles.arrowDisabled}
|
||||
ref={arrowProps.ref}
|
||||
style={arrowProps.style}
|
||||
/>
|
||||
{
|
||||
this.state.isOpen ?
|
||||
<div
|
||||
className={classNames(
|
||||
styles.tooltip,
|
||||
styles[kind]
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={bodyClassName}
|
||||
>
|
||||
{tooltip}
|
||||
</div>
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Popper>
|
||||
</Portal>
|
||||
</Manager>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
216
frontend/src/Components/Tooltip/Tooltip.tsx
Normal file
216
frontend/src/Components/Tooltip/Tooltip.tsx
Normal file
|
|
@ -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<Kind, keyof typeof styles>;
|
||||
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 (
|
||||
<Manager>
|
||||
<Reference>
|
||||
{({ ref }) => (
|
||||
<span
|
||||
ref={ref}
|
||||
className={className}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{anchor}
|
||||
</span>
|
||||
)}
|
||||
</Reference>
|
||||
|
||||
<Portal>
|
||||
<Popper
|
||||
// @ts-expect-error - PopperJS types are not in sync with our position types.
|
||||
placement={position}
|
||||
// Disable events to improve performance when many tooltips
|
||||
// are shown (Quality Definitions for example).
|
||||
eventsEnabled={false}
|
||||
modifiers={{
|
||||
computeMaxHeight: {
|
||||
order: 851,
|
||||
enabled: true,
|
||||
fn: computeMaxSize,
|
||||
},
|
||||
preventOverflow: {
|
||||
// Fixes positioning for tooltips in the queue
|
||||
// and likely others.
|
||||
escapeWithReference: false,
|
||||
},
|
||||
flip: {
|
||||
enabled: canFlip,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{({ ref, style, placement, arrowProps, scheduleUpdate }) => {
|
||||
updater.current = scheduleUpdate;
|
||||
|
||||
const popperPlacement = placement
|
||||
? placement.split('-')[0]
|
||||
: position;
|
||||
const vertical =
|
||||
popperPlacement === 'top' || popperPlacement === 'bottom';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
styles.tooltipContainer,
|
||||
vertical
|
||||
? styles.verticalContainer
|
||||
: styles.horizontalContainer
|
||||
)}
|
||||
style={style}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<div
|
||||
ref={arrowProps.ref}
|
||||
className={
|
||||
isOpen
|
||||
? classNames(
|
||||
styles.arrow,
|
||||
styles[kind],
|
||||
// @ts-expect-error - is a string that may not exist in styles
|
||||
styles[popperPlacement]
|
||||
)
|
||||
: styles.arrowDisabled
|
||||
}
|
||||
style={arrowProps.style}
|
||||
/>
|
||||
{isOpen ? (
|
||||
<div className={classNames(styles.tooltip, styles[kind])}>
|
||||
<div className={bodyClassName}>{tooltip}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Popper>
|
||||
</Portal>
|
||||
</Manager>
|
||||
);
|
||||
}
|
||||
|
||||
export default Tooltip;
|
||||
Loading…
Reference in a new issue