Use floating UI for Tooltip

Fixed: Tooltips cutoff by edge of screen
Closes #7705
This commit is contained in:
Mark McDowall 2025-03-09 17:42:33 -07:00
parent 38cd63ec04
commit a6e6b7518d
8 changed files with 157 additions and 370 deletions

View file

@ -61,7 +61,7 @@ function QueueDetails(props: QueueDetailsProps) {
anchor={progressBar!}
title={`${state} - ${progress.toFixed(1)}%`}
body={<div>{title}</div>}
position={tooltipPositions.LEFT}
position="bottom-start"
/>
);
}

View file

@ -1,20 +1,9 @@
import { useCallback, useEffect } from 'react';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import useTheme from 'Helpers/Hooks/useTheme';
import themes from 'Styles/Themes';
import AppState from './State/AppState';
function createThemeSelector() {
return createSelector(
(state: AppState) => state.settings.ui.item.theme || window.Sonarr.theme,
(theme) => {
return theme;
}
);
}
function ApplyTheme() {
const theme = useSelector(createThemeSelector());
const theme = useTheme();
const updateCSSVariables = useCallback(() => {
Object.entries(themes[theme]).forEach(([key, value]) => {

View file

@ -1,6 +1,5 @@
.tooltipContainer {
z-index: $popperZIndex;
margin: 10px;
}
.tooltip {
@ -18,174 +17,24 @@
}
}
.arrow,
.arrow::after {
position: absolute;
display: block;
width: 0;
height: 0;
border-width: 11px;
border-style: solid;
border-color: transparent;
}
.arrowDisabled {
display: none;
}
.arrow::after {
border-width: 10px;
content: '';
}
.top {
bottom: -11px;
margin-left: -11px;
border-bottom-width: 0;
&::after {
bottom: 1px;
margin-left: -10px;
border-bottom-width: 0;
content: ' ';
&.default {
border-top-color: var(--popoverArrowBorderColor);
}
&.inverse {
border-top-color: var(--popoverArrowBorderInverseColor);
}
}
&.default {
border-top-color: var(--popoverArrowBorderColor);
}
&.inverse {
border-top-color: var(--popoverArrowBorderInverseColor);
}
}
.right {
left: -11px;
margin-top: -11px;
border-left-width: 0;
&::after {
bottom: -10px;
left: 1px;
border-left-width: 0;
content: ' ';
&.default {
border-right-color: var(--popoverArrowBorderColor);
}
&.inverse {
border-right-color: var(--popoverArrowBorderInverseColor);
}
}
&.default {
border-right-color: var(--popoverArrowBorderColor);
}
&.inverse {
border-right-color: var(--popoverArrowBorderInverseColor);
}
}
.bottom {
top: -11px;
margin-left: -11px;
border-top-width: 0;
&::after {
top: 1px;
margin-left: -10px;
border-top-width: 0;
content: ' ';
&.default {
border-bottom-color: var(--popoverArrowBorderColor);
}
&.inverse {
border-bottom-color: var(--popoverArrowBorderInverseColor);
}
}
&.default {
border-bottom-color: var(--popoverArrowBorderColor);
}
&.inverse {
border-bottom-color: var(--popoverArrowBorderInverseColor);
}
}
.left {
right: -11px;
margin-top: -11px;
border-right-width: 0;
&::after {
right: 1px;
bottom: -10px;
border-right-width: 0;
content: ' ';
&.default {
border-left-color: var(--popoverArrowBorderColor);
}
&.inverse {
border-left-color: var(--popoverArrowBorderInverseColor);
}
}
&.default {
border-left-color: var(--popoverArrowBorderColor);
}
&.inverse {
border-left-color: var(--popoverArrowBorderInverseColor);
}
}
.body {
padding: 5px;
}
.verticalContainer {
max-height: 300px;
}
.horizontalContainer {
max-width: calc($breakpointExtraSmall - 20px);
}
@media only screen and (min-width: $breakpointExtraSmall) {
.horizontalContainer {
.tooltip {
max-width: calc($breakpointSmall * 0.8);
}
}
@media only screen and (min-width: $breakpointSmall) {
.horizontalContainer {
.tooltip {
max-width: calc($breakpointMedium * 0.8);
}
}
@media only screen and (min-width: $breakpointMedium) {
.horizontalContainer {
.tooltip {
max-width: calc($breakpointLarge * 0.8);
}
}
/* @media only screen and (max-width: $breakpointLarge) {
.horizontalContainer {
max-width: calc($breakpointLarge * 0.8);
}
} */

View file

@ -1,19 +1,11 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'arrow': string;
'arrowDisabled': string;
'body': string;
'bottom': string;
'default': string;
'horizontalContainer': string;
'inverse': string;
'left': string;
'right': string;
'tooltip': string;
'tooltipContainer': string;
'top': string;
'verticalContainer': string;
}
export const cssExports: CssExports;
export default cssExports;

View file

@ -1,17 +1,25 @@
import {
arrow,
autoUpdate,
flip,
FloatingArrow,
FloatingPortal,
offset,
Placement,
safePolygon,
shift,
useClick,
useDismiss,
useFloating,
useHover,
useInteractions,
} from '@floating-ui/react';
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 React, { useRef, useState } from 'react';
import { useThemeColor } from 'Helpers/Hooks/useTheme';
import { kinds } from 'Helpers/Props';
import { Kind } from 'Helpers/Props/kinds';
import dimensions from 'Styles/Variables/dimensions';
import { isMobile as isMobileUtil } from 'Utilities/browser';
import { isMobile } from 'Utilities/browser';
import styles from './Tooltip.css';
export interface TooltipProps {
@ -19,8 +27,8 @@ export interface TooltipProps {
bodyClassName?: string;
anchor: React.ReactNode;
tooltip: string | React.ReactNode;
kind?: Extract<Kind, keyof typeof styles>;
position?: (typeof tooltipPositions.all)[number];
kind?: Extract<Kind, 'default' | 'inverse'>;
position?: Placement;
canFlip?: boolean;
}
function Tooltip(props: TooltipProps) {
@ -30,196 +38,76 @@ function Tooltip(props: TooltipProps) {
anchor,
tooltip,
kind = kinds.DEFAULT,
position = tooltipPositions.TOP,
canFlip = false,
position,
canFlip = true,
} = props;
const closeTimeout = useRef<ReturnType<typeof setTimeout>>();
const updater = useRef<(() => void) | null>(null);
const arrowColor = useThemeColor(
kind === 'inverse'
? 'popoverArrowBorderInverseColor'
: 'popoverArrowBorderColor'
);
const [isOpen, setIsOpen] = useState(false);
const handleClick = useCallback(() => {
if (!isMobileUtil()) {
return;
}
const arrowRef = useRef(null);
setIsOpen((isOpen) => {
return !isOpen;
});
}, [setIsOpen]);
const handleMouseEnterAnchor = 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) {
clearTimeout(closeTimeout.current);
}
setIsOpen(true);
}, [setIsOpen]);
const handleMouseEnterTooltip = useCallback(() => {
if (closeTimeout.current) {
clearTimeout(closeTimeout.current);
}
setIsOpen(true);
}, [setIsOpen]);
const handleMouseLeave = useCallback(() => {
// Still listen for mouse leave on mobile to allow clicks outside to close the tooltip.
clearTimeout(closeTimeout.current);
closeTimeout.current = 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(() => {
if (updater.current && isOpen) {
updater.current();
}
const { refs, context, floatingStyles } = useFloating({
middleware: [
arrow({
element: arrowRef,
}),
flip({
crossAxis: canFlip,
mainAxis: canFlip,
}),
offset({ mainAxis: 10 }),
shift(),
],
open: isOpen,
placement: position,
whileElementsMounted: autoUpdate,
onOpenChange: setIsOpen,
});
useEffect(() => {
return () => {
if (closeTimeout.current) {
clearTimeout(closeTimeout.current);
}
};
}, []);
const click = useClick(context, {
enabled: isMobile(),
});
const dismiss = useDismiss(context);
const hover = useHover(context, {
handleClose: safePolygon(),
});
const { getReferenceProps, getFloatingProps } = useInteractions([
click,
dismiss,
hover,
]);
return (
<Manager>
<Reference>
{({ ref }) => (
<span
ref={ref}
className={className}
onClick={handleClick}
onMouseEnter={handleMouseEnterAnchor}
onMouseLeave={handleMouseLeave}
<>
<span
ref={refs.setReference}
{...getReferenceProps()}
className={className}
>
{anchor}
</span>
{isOpen ? (
<FloatingPortal id="portal-root">
<div
ref={refs.setFloating}
className={styles.tooltipContainer}
style={floatingStyles}
{...getFloatingProps()}
>
{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={handleMouseEnterTooltip}
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>
<FloatingArrow ref={arrowRef} context={context} fill={arrowColor} />
<div className={classNames(styles.tooltip, styles[kind])}>
<div className={bodyClassName}>{tooltip}</div>
</div>
</div>
</FloatingPortal>
) : null}
</>
);
}

View file

@ -0,0 +1,27 @@
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import themes from 'Styles/Themes';
function createThemeSelector() {
return createSelector(
(state: AppState) => state.settings.ui.item.theme || window.Sonarr.theme,
(theme) => {
return theme;
}
);
}
const useTheme = () => {
return useSelector(createThemeSelector());
};
export default useTheme;
export const useThemeColor = (color: string) => {
const theme = useTheme();
const themeVariables = themes[theme];
// @ts-expect-error - themeVariables is a string indexable type
return themeVariables[color];
};

View file

@ -21,6 +21,7 @@
"defaults"
],
"dependencies": {
"@floating-ui/react": "0.27.5",
"@fortawesome/fontawesome-free": "6.7.1",
"@fortawesome/fontawesome-svg-core": "6.7.1",
"@fortawesome/free-regular-svg-icons": "6.7.1",

View file

@ -1033,6 +1033,42 @@
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.1.tgz#de633db3ec2ef6a3c89e2f19038063e8a122e2c2"
integrity sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==
"@floating-ui/core@^1.6.0":
version "1.6.9"
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.6.9.tgz#64d1da251433019dafa091de9b2886ff35ec14e6"
integrity sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==
dependencies:
"@floating-ui/utils" "^0.2.9"
"@floating-ui/dom@^1.0.0":
version "1.6.13"
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.13.tgz#a8a938532aea27a95121ec16e667a7cbe8c59e34"
integrity sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==
dependencies:
"@floating-ui/core" "^1.6.0"
"@floating-ui/utils" "^0.2.9"
"@floating-ui/react-dom@^2.1.2":
version "2.1.2"
resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.1.2.tgz#a1349bbf6a0e5cb5ded55d023766f20a4d439a31"
integrity sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==
dependencies:
"@floating-ui/dom" "^1.0.0"
"@floating-ui/react@0.27.5":
version "0.27.5"
resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.27.5.tgz#27a6e63a8ef35eb8712ef304a154ea706da26814"
integrity sha512-BX3jKxo39Ba05pflcQmqPPwc0qdNsdNi/eweAFtoIdrJWNen2sVEWMEac3i6jU55Qfx+lOcdMNKYn2CtWmlnOQ==
dependencies:
"@floating-ui/react-dom" "^2.1.2"
"@floating-ui/utils" "^0.2.9"
tabbable "^6.0.0"
"@floating-ui/utils@^0.2.9":
version "0.2.9"
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.9.tgz#50dea3616bc8191fb8e112283b49eaff03e78429"
integrity sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==
"@fortawesome/fontawesome-common-types@6.7.1":
version "6.7.1"
resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.7.1.tgz#6201640f39fdcf8e41cd9d1a92b2da3a96817fa4"
@ -6514,6 +6550,11 @@ svg-tags@^1.0.0:
resolved "https://registry.yarnpkg.com/svg-tags/-/svg-tags-1.0.0.tgz#58f71cee3bd519b59d4b2a843b6c7de64ac04764"
integrity sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==
tabbable@^6.0.0:
version "6.2.0"
resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.2.0.tgz#732fb62bc0175cfcec257330be187dcfba1f3b97"
integrity sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==
table@^6.8.1:
version "6.8.2"
resolved "https://registry.yarnpkg.com/table/-/table-6.8.2.tgz#c5504ccf201213fa227248bdc8c5569716ac6c58"