mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
843 lines
29 KiB
TypeScript
843 lines
29 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import Accessibility from '@app/features/accessibility/state/Accessibility';
|
|
import {useMergeRefs} from '@app/features/app/hooks/useMergeRefs';
|
|
import {Logger} from '@app/features/platform/utils/AppLogger';
|
|
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
|
import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
|
|
import {usePortalHost} from '@app/features/ui/overlay/PortalHostContext';
|
|
import ContextMenuState from '@app/features/ui/state/ContextMenu';
|
|
import KeyboardMode from '@app/features/ui/state/KeyboardMode';
|
|
import MobileLayout from '@app/features/ui/state/MobileLayout';
|
|
import styles from '@app/features/ui/tooltip/Tooltip.module.css';
|
|
import {useExclusiveTooltip} from '@app/features/ui/tooltip/TooltipExclusivity';
|
|
import {isTooltipHandoffWarm, markTooltipOpen} from '@app/features/ui/tooltip/TooltipHandoff';
|
|
import {
|
|
getTooltipScrollSuppressRemainingMs,
|
|
subscribeTooltipScrollHide,
|
|
} from '@app/features/ui/tooltip/TooltipScrollCoordinator';
|
|
import {
|
|
createTooltipSnapshot,
|
|
getTooltipStateValue,
|
|
selectTooltipModel,
|
|
type TooltipEnvironment,
|
|
type TooltipMachineEvent,
|
|
tooltipSnapshotsAreEquivalent,
|
|
transitionTooltipSnapshot,
|
|
} from '@app/features/ui/tooltip/TooltipStateMachine';
|
|
import {appZoomCssPx, appZoomLayoutPx} from '@app/features/ui/utils/AppZoomUtils';
|
|
import {getReducedMotionProps, TOOLTIP_MOTION} from '@app/features/ui/utils/ReducedMotionAnimation';
|
|
import {
|
|
canUseWindowFocusedHoverControls,
|
|
subscribeWindowHoverControlsChange,
|
|
} from '@app/features/ui/utils/WindowFocusInteractionGuard';
|
|
import {elementSupportsRef} from '@app/lib/react';
|
|
import type {ExtendedDocument} from '@app/types/browser.d';
|
|
import {FloatingPortal} from '@floating-ui/react';
|
|
import {arrow, autoUpdate, computePosition, flip, offset, shift} from '@floating-ui/react-dom';
|
|
import {clsx} from 'clsx';
|
|
import {AnimatePresence, motion} from 'framer-motion';
|
|
import {observer} from 'mobx-react-lite';
|
|
import React, {useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState} from 'react';
|
|
|
|
const logger = new Logger('Tooltip');
|
|
|
|
export type TooltipPosition = 'top' | 'left' | 'right' | 'bottom';
|
|
export type TooltipType = 'normal' | 'error';
|
|
|
|
const TOOLTIP_AUTO_UPDATE_OPTIONS = {
|
|
ancestorScroll: false,
|
|
ancestorResize: true,
|
|
elementResize: true,
|
|
layoutShift: true,
|
|
} as const;
|
|
|
|
export const getFullscreenOverflowBoundary = (target: HTMLElement): HTMLElement | null => {
|
|
const ownerDocument = target.ownerDocument as ExtendedDocument;
|
|
const ownerWindow = ownerDocument.defaultView;
|
|
if (!ownerWindow) return null;
|
|
const fullscreenElement =
|
|
ownerDocument.fullscreenElement ??
|
|
ownerDocument.webkitFullscreenElement ??
|
|
ownerDocument.mozFullScreenElement ??
|
|
ownerDocument.msFullscreenElement ??
|
|
null;
|
|
if (!(fullscreenElement instanceof ownerWindow.HTMLElement)) return null;
|
|
return fullscreenElement.contains(target) ? fullscreenElement : null;
|
|
};
|
|
|
|
const tooltipPortalRoots = new WeakMap<Document, HTMLElement>();
|
|
|
|
const getTooltipPortalRoot = (ownerDocument: Document): HTMLElement => {
|
|
const existingRoot = tooltipPortalRoots.get(ownerDocument);
|
|
if (existingRoot) {
|
|
return existingRoot;
|
|
}
|
|
const root = ownerDocument.createElement('div');
|
|
root.className = styles.tooltips;
|
|
root.setAttribute('data-tooltip-portal-root', 'true');
|
|
ownerDocument.body.appendChild(root);
|
|
tooltipPortalRoots.set(ownerDocument, root);
|
|
return root;
|
|
};
|
|
export const useTooltipPortalRoot = (enabled = true, targetDocument?: Document): HTMLElement | undefined => {
|
|
const [root, setRoot] = useState<HTMLElement | undefined>(undefined);
|
|
const portalHost = usePortalHost();
|
|
const resolvedDocument = targetDocument ?? document;
|
|
const scopedPortalHost = portalHost && portalHost.ownerDocument === resolvedDocument ? portalHost : null;
|
|
useLayoutEffect(() => {
|
|
if (!enabled) return;
|
|
if (scopedPortalHost) {
|
|
setRoot(undefined);
|
|
return;
|
|
}
|
|
setRoot(getTooltipPortalRoot(resolvedDocument));
|
|
}, [enabled, resolvedDocument, scopedPortalHost]);
|
|
if (!enabled) return undefined;
|
|
return scopedPortalHost ?? root;
|
|
};
|
|
const MAX_WIDTH_MAP = {
|
|
default: remFromPx(190),
|
|
xl: remFromPx(350),
|
|
none: 'none' as const,
|
|
};
|
|
const TooltipPositionToStyle: Record<TooltipPosition, string> = {
|
|
top: styles.tooltipTop,
|
|
left: styles.tooltipLeft,
|
|
right: styles.tooltipRight,
|
|
bottom: styles.tooltipBottom,
|
|
};
|
|
const TooltipTypeToStyle: Record<TooltipType, string> = {
|
|
normal: styles.tooltipPrimary,
|
|
error: styles.tooltipRed,
|
|
};
|
|
|
|
interface TooltipProps {
|
|
text: string | (() => React.ReactNode);
|
|
type?: TooltipType;
|
|
position?: TooltipPosition;
|
|
align?: 'center' | 'top' | 'bottom' | 'left' | 'right';
|
|
nudge?: number;
|
|
delay?: number;
|
|
padding?: number;
|
|
maxWidth?: 'default' | 'xl' | 'none';
|
|
size?: 'default' | 'large';
|
|
openOnMountHover?: boolean;
|
|
allowWhenContextMenuOpen?: boolean;
|
|
children?: React.ReactElement;
|
|
}
|
|
|
|
type TooltipVisibilityDriver = 'hover' | 'focus';
|
|
|
|
const nodeHasAccessibleText = (node: React.ReactNode): boolean => {
|
|
if (typeof node === 'string') return node.trim().length > 0;
|
|
if (typeof node === 'number') return true;
|
|
if (Array.isArray(node)) return node.some(nodeHasAccessibleText);
|
|
if (!React.isValidElement(node)) return false;
|
|
const props = node.props as {
|
|
children?: React.ReactNode;
|
|
'aria-hidden'?: boolean | 'true' | 'false';
|
|
};
|
|
if (props['aria-hidden'] === true || props['aria-hidden'] === 'true') return false;
|
|
return nodeHasAccessibleText(props.children);
|
|
};
|
|
|
|
const tooltipHasRenderableContent = (text: TooltipProps['text']): boolean => {
|
|
if (typeof text === 'function') return true;
|
|
if (typeof text === 'string') return text.trim().length > 0;
|
|
return !!text;
|
|
};
|
|
|
|
const TooltipContainer = React.forwardRef<
|
|
HTMLDivElement,
|
|
{
|
|
text: string | (() => React.ReactNode);
|
|
type: TooltipType;
|
|
position: TooltipPosition;
|
|
maxWidth: 'default' | 'xl' | 'none';
|
|
size: 'default' | 'large';
|
|
arrowRef: React.RefObject<HTMLDivElement | null> | React.RefObject<HTMLDivElement>;
|
|
arrowX?: number;
|
|
arrowY?: number;
|
|
}
|
|
>(({text, type, position, maxWidth, size, arrowRef, arrowX, arrowY}, ref) => {
|
|
const content = typeof text === 'function' ? text() : text;
|
|
if (
|
|
!content ||
|
|
(Array.isArray(content) && content.length === 0) ||
|
|
(typeof content === 'string' && content.trim().length === 0)
|
|
) {
|
|
return null;
|
|
}
|
|
const arrowStyle: React.CSSProperties = {};
|
|
if (position === 'top' || position === 'bottom') {
|
|
if (arrowX != null) {
|
|
arrowStyle.left = appZoomCssPx(arrowX);
|
|
arrowStyle.marginLeft = 0;
|
|
}
|
|
} else {
|
|
if (arrowY != null) {
|
|
arrowStyle.top = appZoomCssPx(arrowY);
|
|
arrowStyle.marginTop = 0;
|
|
}
|
|
}
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
className={clsx(styles.tooltip, TooltipPositionToStyle[position], TooltipTypeToStyle[type])}
|
|
style={{maxWidth: MAX_WIDTH_MAP[maxWidth]}}
|
|
data-flx="ui.tooltip.tooltip.tooltip-container.tooltip"
|
|
>
|
|
<div
|
|
ref={arrowRef}
|
|
className={clsx(styles.tooltipPointer, styles.tooltipPointerBg)}
|
|
style={arrowStyle}
|
|
data-flx="ui.tooltip.tooltip.tooltip-container.tooltip-pointer"
|
|
/>
|
|
<div
|
|
className={clsx(styles.tooltipPointer)}
|
|
style={arrowStyle}
|
|
data-flx="ui.tooltip.tooltip.tooltip-container.tooltip-pointer--2"
|
|
/>
|
|
<div
|
|
className={size === 'large' ? styles.tooltipContentLarge : styles.tooltipContent}
|
|
data-flx="ui.tooltip.tooltip.tooltip-container.tooltip-content"
|
|
>
|
|
{content}
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
TooltipContainer.displayName = 'TooltipContainer';
|
|
|
|
export const Tooltip = observer(
|
|
({
|
|
text,
|
|
type = 'normal',
|
|
position = 'top',
|
|
nudge = 0,
|
|
delay = 0,
|
|
padding = 8,
|
|
maxWidth = 'default',
|
|
size = 'default',
|
|
openOnMountHover = true,
|
|
allowWhenContextMenuOpen = false,
|
|
children,
|
|
}: TooltipProps) => {
|
|
const tooltipId = useId();
|
|
const keyboardModeEnabled = KeyboardMode.keyboardModeEnabled;
|
|
const mobileLayout = MobileLayout;
|
|
type TooltipChildProps = React.HTMLAttributes<HTMLElement> & {disabled?: boolean; ref?: React.Ref<HTMLElement>};
|
|
const child = children && React.isValidElement<TooltipChildProps>(children) ? React.Children.only(children) : null;
|
|
const childSupportsRef = child ? elementSupportsRef(child) : false;
|
|
const childRef = childSupportsRef && child ? (child.props.ref ?? null) : null;
|
|
const childProps = (child?.props ?? {}) as TooltipChildProps;
|
|
const {
|
|
onMouseEnter: childMouseEnter,
|
|
onMouseLeave: childMouseLeave,
|
|
onClick: childClick,
|
|
onTouchStart: childTouchStart,
|
|
onTouchEnd: childTouchEnd,
|
|
onTouchCancel: childTouchCancel,
|
|
onMouseDownCapture: childMouseDownCapture,
|
|
onPointerDownCapture: childPointerDownCapture,
|
|
onFocus: childFocus,
|
|
onBlur: childBlur,
|
|
onKeyDown: childKeyDown,
|
|
} = childProps;
|
|
const isDisabled = Boolean(childProps.disabled || childProps['aria-disabled']);
|
|
const hasRenderableContent = tooltipHasRenderableContent(text);
|
|
const targetRef = useRef<HTMLElement | null>(null);
|
|
const getTooltipOwnerDocument = useCallback((): Document => targetRef.current?.ownerDocument ?? document, []);
|
|
const getTooltipOwnerWindow = useCallback((): Window => {
|
|
return getTooltipOwnerDocument().defaultView ?? window;
|
|
}, [getTooltipOwnerDocument]);
|
|
const canUseTooltipHoverControls = useCallback((): boolean => {
|
|
return canUseWindowFocusedHoverControls(getTooltipOwnerDocument().documentElement);
|
|
}, [getTooltipOwnerDocument]);
|
|
const isTooltipContextMenuOpen = useCallback((): boolean => {
|
|
if (allowWhenContextMenuOpen) return false;
|
|
return ContextMenuState.getContextMenu(getTooltipOwnerDocument()) != null;
|
|
}, [allowWhenContextMenuOpen, getTooltipOwnerDocument]);
|
|
const contextMenuOpen = isTooltipContextMenuOpen();
|
|
const tooltipEnvironment = useMemo(
|
|
(): TooltipEnvironment => ({
|
|
disabled: isDisabled,
|
|
mobileEnabled: mobileLayout.enabled,
|
|
contextMenuOpen,
|
|
keyboardModeEnabled,
|
|
hoverControlsEnabled: canUseTooltipHoverControls(),
|
|
hasRenderableContent,
|
|
}),
|
|
[
|
|
canUseTooltipHoverControls,
|
|
contextMenuOpen,
|
|
hasRenderableContent,
|
|
isDisabled,
|
|
keyboardModeEnabled,
|
|
mobileLayout.enabled,
|
|
],
|
|
);
|
|
const [tooltipSnapshot, setTooltipSnapshot] = useState(() => createTooltipSnapshot(tooltipEnvironment));
|
|
const tooltipModel = selectTooltipModel(tooltipSnapshot);
|
|
const tooltipStateValue = getTooltipStateValue(tooltipSnapshot);
|
|
const shouldRenderTooltip = tooltipModel.shouldRender;
|
|
const hasActiveVisibilityState = tooltipStateValue !== 'hidden' || tooltipModel.hasVisibilityDriver;
|
|
const portalRoot = useTooltipPortalRoot(shouldRenderTooltip, getTooltipOwnerDocument());
|
|
const timerRef = useRef<number | null>(null);
|
|
const timerWindowRef = useRef<Window | null>(null);
|
|
const pendingDelayMsRef = useRef(0);
|
|
const longPressTimeoutRef = useRef<number | null>(null);
|
|
const pointerFocusTimeoutRef = useRef<number | null>(null);
|
|
const pointerFocusWindowRef = useRef<Window | null>(null);
|
|
const ignoreNextFocusRef = useRef(false);
|
|
const arrowRef = useRef<HTMLDivElement>(null);
|
|
const tooltipRef = useRef<HTMLDivElement>(null);
|
|
const cleanupRef = useRef<(() => void) | null>(null);
|
|
const rafRef = useRef<number | null>(null);
|
|
const rafWindowRef = useRef<Window | null>(null);
|
|
const isCalculatingRef = useRef(false);
|
|
const mountedRef = useRef(true);
|
|
const positionRequestIdRef = useRef(0);
|
|
const shouldRenderTooltipRef = useRef(shouldRenderTooltip);
|
|
const tooltipSnapshotRef = useRef(tooltipSnapshot);
|
|
const tooltipModelRef = useRef(tooltipModel);
|
|
const environmentRef = useRef(tooltipEnvironment);
|
|
shouldRenderTooltipRef.current = shouldRenderTooltip;
|
|
tooltipSnapshotRef.current = tooltipSnapshot;
|
|
tooltipModelRef.current = tooltipModel;
|
|
environmentRef.current = tooltipEnvironment;
|
|
const isCrossDocumentTooltip = portalRoot != null && portalRoot.ownerDocument !== document;
|
|
const tooltipMotion = getReducedMotionProps(
|
|
TOOLTIP_MOTION,
|
|
Accessibility.useReducedMotion || isCrossDocumentTooltip,
|
|
);
|
|
const [tooltipState, setTooltipState] = useState({
|
|
x: 0,
|
|
y: 0,
|
|
arrowX: 0,
|
|
arrowY: 0,
|
|
isReady: false,
|
|
actualPlacement: position,
|
|
});
|
|
const sendTooltipEvent = useCallback((event: TooltipMachineEvent) => {
|
|
if (!mountedRef.current) return;
|
|
const prev = tooltipSnapshotRef.current;
|
|
const next = transitionTooltipSnapshot(prev, event);
|
|
if (tooltipSnapshotsAreEquivalent(prev, next)) return;
|
|
tooltipSnapshotRef.current = next;
|
|
setTooltipSnapshot(next);
|
|
}, []);
|
|
const refreshTooltipEnvironment = useCallback(() => {
|
|
const environment = {
|
|
...environmentRef.current,
|
|
contextMenuOpen: isTooltipContextMenuOpen(),
|
|
hoverControlsEnabled: canUseTooltipHoverControls(),
|
|
};
|
|
environmentRef.current = environment;
|
|
sendTooltipEvent({type: 'tooltip.environmentChanged', environment});
|
|
}, [canUseTooltipHoverControls, isTooltipContextMenuOpen, sendTooltipEvent]);
|
|
const getTextString = useCallback((): string => {
|
|
return typeof text === 'string' ? text : '';
|
|
}, [text]);
|
|
const clearDelayTimer = useCallback(() => {
|
|
if (timerRef.current != null) {
|
|
(timerWindowRef.current ?? window).clearTimeout(timerRef.current);
|
|
timerRef.current = null;
|
|
timerWindowRef.current = null;
|
|
}
|
|
}, []);
|
|
const clearPointerFocusTimeout = useCallback(() => {
|
|
if (pointerFocusTimeoutRef.current != null) {
|
|
(pointerFocusWindowRef.current ?? window).clearTimeout(pointerFocusTimeoutRef.current);
|
|
pointerFocusTimeoutRef.current = null;
|
|
pointerFocusWindowRef.current = null;
|
|
}
|
|
}, []);
|
|
const markPointerFocus = useCallback(() => {
|
|
ignoreNextFocusRef.current = true;
|
|
clearPointerFocusTimeout();
|
|
const ownerWindow = getTooltipOwnerWindow();
|
|
pointerFocusWindowRef.current = ownerWindow;
|
|
pointerFocusTimeoutRef.current = ownerWindow.setTimeout(() => {
|
|
pointerFocusTimeoutRef.current = null;
|
|
pointerFocusWindowRef.current = null;
|
|
ignoreNextFocusRef.current = false;
|
|
}, 0);
|
|
}, [clearPointerFocusTimeout, getTooltipOwnerWindow]);
|
|
const cancelRaf = useCallback(() => {
|
|
if (rafRef.current != null) {
|
|
(rafWindowRef.current ?? window).cancelAnimationFrame(rafRef.current);
|
|
rafRef.current = null;
|
|
rafWindowRef.current = null;
|
|
}
|
|
}, []);
|
|
const resetTooltipPosition = useCallback(() => {
|
|
positionRequestIdRef.current += 1;
|
|
setTooltipState((prev) =>
|
|
prev.isReady || prev.actualPlacement !== position ? {...prev, isReady: false, actualPlacement: position} : prev,
|
|
);
|
|
}, [position]);
|
|
const dismissTooltip = useCallback(() => {
|
|
clearDelayTimer();
|
|
cancelRaf();
|
|
const model = tooltipModelRef.current;
|
|
if (model.state === 'hidden' && !model.hasVisibilityDriver && !model.delayPending) return;
|
|
sendTooltipEvent({type: 'tooltip.dismiss'});
|
|
resetTooltipPosition();
|
|
}, [cancelRaf, clearDelayTimer, resetTooltipPosition, sendTooltipEvent]);
|
|
useExclusiveTooltip(shouldRenderTooltip, dismissTooltip);
|
|
const updatePositionNow = useCallback(async () => {
|
|
if (
|
|
!shouldRenderTooltipRef.current ||
|
|
!targetRef.current ||
|
|
!tooltipRef.current ||
|
|
isCalculatingRef.current ||
|
|
!hasRenderableContent
|
|
) {
|
|
return;
|
|
}
|
|
const requestId = ++positionRequestIdRef.current;
|
|
isCalculatingRef.current = true;
|
|
try {
|
|
const target = targetRef.current;
|
|
const tooltip = tooltipRef.current;
|
|
Object.assign(tooltip.style, {
|
|
position: 'fixed',
|
|
left: '-9999px',
|
|
top: '-9999px',
|
|
} as CSSStyleDeclaration);
|
|
const overflowBoundary = getFullscreenOverflowBoundary(target);
|
|
const boundaryOptions = overflowBoundary ? {boundary: overflowBoundary} : undefined;
|
|
const middleware = [
|
|
offset(padding + nudge),
|
|
flip(boundaryOptions),
|
|
shift({padding: 8, ...boundaryOptions}),
|
|
...(arrowRef.current ? [arrow({element: arrowRef.current})] : []),
|
|
];
|
|
const {x, y, placement, middlewareData} = await computePosition(target, tooltip, {
|
|
placement: position,
|
|
strategy: 'fixed',
|
|
middleware,
|
|
});
|
|
if (
|
|
!mountedRef.current ||
|
|
!shouldRenderTooltipRef.current ||
|
|
positionRequestIdRef.current !== requestId ||
|
|
targetRef.current !== target ||
|
|
tooltipRef.current !== tooltip
|
|
) {
|
|
return;
|
|
}
|
|
setTooltipState((prev) => {
|
|
const next = {
|
|
x,
|
|
y,
|
|
arrowX: middlewareData.arrow?.x ?? 0,
|
|
arrowY: middlewareData.arrow?.y ?? 0,
|
|
isReady: true,
|
|
actualPlacement: placement.split('-')[0] as TooltipPosition,
|
|
};
|
|
return prev.x !== next.x ||
|
|
prev.y !== next.y ||
|
|
prev.arrowX !== next.arrowX ||
|
|
prev.arrowY !== next.arrowY ||
|
|
prev.isReady !== next.isReady ||
|
|
prev.actualPlacement !== next.actualPlacement
|
|
? next
|
|
: prev;
|
|
});
|
|
Object.assign(tooltip.style, {left: appZoomCssPx(x), top: appZoomCssPx(y)} as CSSStyleDeclaration);
|
|
} catch (error) {
|
|
logger.error('Error positioning tooltip:', error);
|
|
if (tooltipRef.current) {
|
|
tooltipRef.current.style.visibility = 'visible';
|
|
}
|
|
} finally {
|
|
isCalculatingRef.current = false;
|
|
}
|
|
}, [hasRenderableContent, nudge, padding, position]);
|
|
const updatePosition = useCallback(() => {
|
|
cancelRaf();
|
|
const ownerWindow = getTooltipOwnerWindow();
|
|
rafWindowRef.current = ownerWindow;
|
|
rafRef.current = ownerWindow.requestAnimationFrame(() => {
|
|
rafRef.current = null;
|
|
rafWindowRef.current = null;
|
|
void updatePositionNow();
|
|
});
|
|
}, [updatePositionNow, cancelRaf, getTooltipOwnerWindow]);
|
|
const getVisibilityDriverDelayMs = useCallback(() => {
|
|
const scrollSuppressRemainingMs = getTooltipScrollSuppressRemainingMs();
|
|
const openDelay = isTooltipHandoffWarm() ? 0 : delay;
|
|
switch (true) {
|
|
case openDelay > 0 || scrollSuppressRemainingMs > 0:
|
|
return Math.max(openDelay, scrollSuppressRemainingMs > 0 ? scrollSuppressRemainingMs + 1 : 0);
|
|
default:
|
|
return 0;
|
|
}
|
|
}, [delay]);
|
|
const beginVisibilityDriver = useCallback(
|
|
(driver: TooltipVisibilityDriver) => {
|
|
refreshTooltipEnvironment();
|
|
const pendingDelayMs = getVisibilityDriverDelayMs();
|
|
pendingDelayMsRef.current = pendingDelayMs;
|
|
switch (driver) {
|
|
case 'hover':
|
|
sendTooltipEvent({
|
|
type: 'tooltip.hoverChanged',
|
|
hovered: true,
|
|
delay: pendingDelayMs > 0,
|
|
});
|
|
return;
|
|
case 'focus':
|
|
sendTooltipEvent({
|
|
type: 'tooltip.focusChanged',
|
|
focused: true,
|
|
delay: pendingDelayMs > 0,
|
|
});
|
|
return;
|
|
}
|
|
},
|
|
[getVisibilityDriverDelayMs, refreshTooltipEnvironment, sendTooltipEvent],
|
|
);
|
|
const endVisibilityDriver = useCallback(
|
|
(driver: TooltipVisibilityDriver) => {
|
|
switch (driver) {
|
|
case 'hover':
|
|
sendTooltipEvent({type: 'tooltip.hoverChanged', hovered: false, delay: false});
|
|
return;
|
|
case 'focus':
|
|
sendTooltipEvent({type: 'tooltip.focusChanged', focused: false, delay: false});
|
|
return;
|
|
}
|
|
},
|
|
[sendTooltipEvent],
|
|
);
|
|
const showMobileToast = useCallback(() => {
|
|
const textContent = getTextString();
|
|
if (textContent) {
|
|
ToastCommands.createToast({
|
|
type: 'info',
|
|
children: textContent,
|
|
timeout: 3000,
|
|
});
|
|
}
|
|
}, [getTextString]);
|
|
const handleLongPressStart = useCallback(() => {
|
|
if (!mobileLayout.enabled || !getTextString()) return;
|
|
if (longPressTimeoutRef.current != null) {
|
|
window.clearTimeout(longPressTimeoutRef.current);
|
|
}
|
|
longPressTimeoutRef.current = window.setTimeout(() => {
|
|
showMobileToast();
|
|
}, 500);
|
|
}, [mobileLayout.enabled, showMobileToast, getTextString]);
|
|
const handleLongPressEnd = useCallback(() => {
|
|
if (longPressTimeoutRef.current != null) {
|
|
window.clearTimeout(longPressTimeoutRef.current);
|
|
longPressTimeoutRef.current = null;
|
|
}
|
|
}, []);
|
|
useEffect(() => {
|
|
sendTooltipEvent({type: 'tooltip.environmentChanged', environment: tooltipEnvironment});
|
|
}, [sendTooltipEvent, tooltipEnvironment]);
|
|
useEffect(() => {
|
|
clearDelayTimer();
|
|
if (tooltipStateValue !== 'delayed') return;
|
|
const ownerWindow = getTooltipOwnerWindow();
|
|
timerWindowRef.current = ownerWindow;
|
|
timerRef.current = ownerWindow.setTimeout(() => {
|
|
timerRef.current = null;
|
|
timerWindowRef.current = null;
|
|
pendingDelayMsRef.current = 0;
|
|
sendTooltipEvent({type: 'tooltip.delayElapsed'});
|
|
}, pendingDelayMsRef.current);
|
|
return clearDelayTimer;
|
|
}, [clearDelayTimer, getTooltipOwnerWindow, sendTooltipEvent, tooltipStateValue]);
|
|
useLayoutEffect(() => {
|
|
if (!shouldRenderTooltip || !targetRef.current || !tooltipRef.current) {
|
|
if (cleanupRef.current) {
|
|
cleanupRef.current();
|
|
cleanupRef.current = null;
|
|
}
|
|
return;
|
|
}
|
|
updatePosition();
|
|
if (targetRef.current.ownerDocument.contains(targetRef.current)) {
|
|
cleanupRef.current = autoUpdate(
|
|
targetRef.current,
|
|
tooltipRef.current,
|
|
updatePosition,
|
|
TOOLTIP_AUTO_UPDATE_OPTIONS,
|
|
);
|
|
}
|
|
return () => {
|
|
if (cleanupRef.current) {
|
|
cleanupRef.current();
|
|
cleanupRef.current = null;
|
|
}
|
|
};
|
|
}, [shouldRenderTooltip, updatePosition]);
|
|
useEffect(() => {
|
|
if (shouldRenderTooltip) return;
|
|
cancelRaf();
|
|
resetTooltipPosition();
|
|
if (cleanupRef.current) {
|
|
cleanupRef.current();
|
|
cleanupRef.current = null;
|
|
}
|
|
}, [cancelRaf, resetTooltipPosition, shouldRenderTooltip]);
|
|
useEffect(() => {
|
|
mountedRef.current = true;
|
|
return () => {
|
|
mountedRef.current = false;
|
|
positionRequestIdRef.current += 1;
|
|
clearDelayTimer();
|
|
if (longPressTimeoutRef.current != null) {
|
|
window.clearTimeout(longPressTimeoutRef.current);
|
|
longPressTimeoutRef.current = null;
|
|
}
|
|
clearPointerFocusTimeout();
|
|
if (cleanupRef.current) {
|
|
cleanupRef.current();
|
|
cleanupRef.current = null;
|
|
}
|
|
cancelRaf();
|
|
};
|
|
}, [cancelRaf, clearDelayTimer, clearPointerFocusTimeout]);
|
|
useEffect(() => {
|
|
if (!shouldRenderTooltip) return;
|
|
return markTooltipOpen();
|
|
}, [shouldRenderTooltip]);
|
|
useEffect(() => {
|
|
if (!shouldRenderTooltip) return;
|
|
return subscribeTooltipScrollHide(dismissTooltip);
|
|
}, [dismissTooltip, shouldRenderTooltip]);
|
|
useEffect(() => {
|
|
if (!hasActiveVisibilityState) return;
|
|
const closeIfHoverControlsDisabled = () => {
|
|
refreshTooltipEnvironment();
|
|
if (!canUseTooltipHoverControls()) {
|
|
dismissTooltip();
|
|
}
|
|
};
|
|
const ownerWindow = getTooltipOwnerWindow();
|
|
const unsubscribe = subscribeWindowHoverControlsChange(closeIfHoverControlsDisabled, ownerWindow);
|
|
ownerWindow.addEventListener('blur', dismissTooltip);
|
|
return () => {
|
|
unsubscribe();
|
|
ownerWindow.removeEventListener('blur', dismissTooltip);
|
|
};
|
|
}, [
|
|
canUseTooltipHoverControls,
|
|
dismissTooltip,
|
|
getTooltipOwnerWindow,
|
|
hasActiveVisibilityState,
|
|
refreshTooltipEnvironment,
|
|
]);
|
|
const mergedRef = useMergeRefs([targetRef, childSupportsRef ? childRef : null]);
|
|
const wrapperRef = useMergeRefs([targetRef]);
|
|
const hasChild = child != null;
|
|
useEffect(() => {
|
|
if (!openOnMountHover || !hasChild || mobileLayout.enabled || isDisabled) return;
|
|
const target = targetRef.current;
|
|
if (!target?.matches(':hover')) return;
|
|
beginVisibilityDriver('hover');
|
|
}, [beginVisibilityDriver, hasChild, isDisabled, mobileLayout.enabled, openOnMountHover]);
|
|
useEffect(() => {
|
|
if (!hasChild || mobileLayout.enabled) return;
|
|
const target = targetRef.current;
|
|
if (!target) return;
|
|
if (target.ownerDocument === document) return;
|
|
const handleNativePointerEnter = () => {
|
|
if (isDisabled) {
|
|
endVisibilityDriver('hover');
|
|
return;
|
|
}
|
|
beginVisibilityDriver('hover');
|
|
};
|
|
const handleNativePointerLeave = () => {
|
|
endVisibilityDriver('hover');
|
|
};
|
|
const handleNativeClick = () => {
|
|
dismissTooltip();
|
|
};
|
|
target.addEventListener('pointerenter', handleNativePointerEnter);
|
|
target.addEventListener('pointerleave', handleNativePointerLeave);
|
|
target.addEventListener('click', handleNativeClick);
|
|
return () => {
|
|
target.removeEventListener('pointerenter', handleNativePointerEnter);
|
|
target.removeEventListener('pointerleave', handleNativePointerLeave);
|
|
target.removeEventListener('click', handleNativeClick);
|
|
};
|
|
}, [beginVisibilityDriver, dismissTooltip, endVisibilityDriver, hasChild, isDisabled, mobileLayout.enabled]);
|
|
useEffect(() => {
|
|
if (!tooltipModel.hovered || tooltipModel.dismissed || mobileLayout.enabled) return;
|
|
const ownerWindow = getTooltipOwnerWindow();
|
|
let frameId: number | null = null;
|
|
const verifyTargetHover = () => {
|
|
const target = targetRef.current;
|
|
if (!target?.matches(':hover')) {
|
|
endVisibilityDriver('hover');
|
|
frameId = null;
|
|
return;
|
|
}
|
|
frameId = ownerWindow.requestAnimationFrame(verifyTargetHover);
|
|
};
|
|
frameId = ownerWindow.requestAnimationFrame(verifyTargetHover);
|
|
return () => {
|
|
if (frameId != null) {
|
|
ownerWindow.cancelAnimationFrame(frameId);
|
|
}
|
|
};
|
|
}, [
|
|
endVisibilityDriver,
|
|
getTooltipOwnerWindow,
|
|
mobileLayout.enabled,
|
|
tooltipModel.dismissed,
|
|
tooltipModel.hovered,
|
|
]);
|
|
if (!child) return null;
|
|
const describedBy = shouldRenderTooltip
|
|
? [childProps['aria-describedby'], tooltipId].filter(Boolean).join(' ')
|
|
: childProps['aria-describedby'];
|
|
const shouldUseTooltipAsName =
|
|
typeof text === 'string' &&
|
|
!childProps['aria-label'] &&
|
|
!childProps['aria-labelledby'] &&
|
|
!nodeHasAccessibleText(childProps.children);
|
|
const tooltipNameProps = shouldUseTooltipAsName ? {'aria-label': text} : {};
|
|
const mobileEventHandlers = mobileLayout.enabled
|
|
? {
|
|
onTouchStart: (event: React.TouchEvent<HTMLElement>) => {
|
|
handleLongPressStart();
|
|
childTouchStart?.(event);
|
|
},
|
|
onTouchEnd: (event: React.TouchEvent<HTMLElement>) => {
|
|
handleLongPressEnd();
|
|
childTouchEnd?.(event);
|
|
},
|
|
onTouchCancel: (event: React.TouchEvent<HTMLElement>) => {
|
|
handleLongPressEnd();
|
|
childTouchCancel?.(event);
|
|
},
|
|
onClick: (event: React.MouseEvent<HTMLElement>) => {
|
|
childClick?.(event);
|
|
},
|
|
}
|
|
: {};
|
|
const desktopEventHandlers = !mobileLayout.enabled
|
|
? {
|
|
onMouseEnter: (event: React.MouseEvent<HTMLElement>) => {
|
|
if (isDisabled) {
|
|
endVisibilityDriver('hover');
|
|
childMouseEnter?.(event);
|
|
return;
|
|
}
|
|
beginVisibilityDriver('hover');
|
|
childMouseEnter?.(event);
|
|
},
|
|
onMouseLeave: (event: React.MouseEvent<HTMLElement>) => {
|
|
endVisibilityDriver('hover');
|
|
childMouseLeave?.(event);
|
|
},
|
|
onClick: (event: React.MouseEvent<HTMLElement>) => {
|
|
dismissTooltip();
|
|
childClick?.(event);
|
|
},
|
|
onPointerDownCapture: (event: React.PointerEvent<HTMLElement>) => {
|
|
markPointerFocus();
|
|
childPointerDownCapture?.(event);
|
|
},
|
|
onMouseDownCapture: (event: React.MouseEvent<HTMLElement>) => {
|
|
markPointerFocus();
|
|
childMouseDownCapture?.(event);
|
|
},
|
|
onFocus: (event: React.FocusEvent<HTMLElement>) => {
|
|
const shouldIgnoreFocus = ignoreNextFocusRef.current;
|
|
ignoreNextFocusRef.current = false;
|
|
if (shouldIgnoreFocus) {
|
|
clearPointerFocusTimeout();
|
|
endVisibilityDriver('focus');
|
|
} else {
|
|
beginVisibilityDriver('focus');
|
|
}
|
|
if (isDisabled || shouldIgnoreFocus) {
|
|
dismissTooltip();
|
|
}
|
|
childFocus?.(event);
|
|
},
|
|
onBlur: (event: React.FocusEvent<HTMLElement>) => {
|
|
endVisibilityDriver('focus');
|
|
childBlur?.(event);
|
|
},
|
|
onKeyDown: (event: React.KeyboardEvent<HTMLElement>) => {
|
|
if (event.key === 'Escape' && shouldRenderTooltip) {
|
|
dismissTooltip();
|
|
}
|
|
childKeyDown?.(event);
|
|
},
|
|
}
|
|
: {};
|
|
const childWithHandlers = React.cloneElement(child, {
|
|
...(childSupportsRef ? {ref: mergedRef} : {}),
|
|
'aria-describedby': describedBy || undefined,
|
|
...tooltipNameProps,
|
|
...desktopEventHandlers,
|
|
...mobileEventHandlers,
|
|
});
|
|
const triggerNode = childSupportsRef ? (
|
|
childWithHandlers
|
|
) : (
|
|
<span className={styles.triggerWrapper} ref={wrapperRef} data-flx="ui.tooltip.tooltip.trigger-wrapper">
|
|
{childWithHandlers}
|
|
</span>
|
|
);
|
|
return (
|
|
<>
|
|
{triggerNode}
|
|
{shouldRenderTooltip && (
|
|
<FloatingPortal root={portalRoot || undefined} data-flx="ui.tooltip.tooltip.floating-portal">
|
|
<AnimatePresence mode="wait" data-flx="ui.tooltip.tooltip.animate-presence">
|
|
<motion.div
|
|
key="tooltip"
|
|
id={tooltipId}
|
|
role="tooltip"
|
|
ref={(node: HTMLDivElement | null) => {
|
|
tooltipRef.current = node;
|
|
if (node && targetRef.current) {
|
|
updatePosition();
|
|
}
|
|
}}
|
|
style={{
|
|
position: 'fixed',
|
|
left: appZoomLayoutPx(tooltipState.x),
|
|
top: appZoomLayoutPx(tooltipState.y),
|
|
zIndex: 'var(--z-index-tooltip)',
|
|
visibility: tooltipState.isReady ? 'visible' : 'hidden',
|
|
}}
|
|
data-flx="ui.tooltip.tooltip.tooltip"
|
|
{...tooltipMotion}
|
|
>
|
|
<TooltipContainer
|
|
text={text}
|
|
type={type}
|
|
position={tooltipState.actualPlacement}
|
|
maxWidth={maxWidth}
|
|
size={size}
|
|
arrowRef={arrowRef}
|
|
arrowX={tooltipState.arrowX}
|
|
arrowY={tooltipState.arrowY}
|
|
data-flx="ui.tooltip.tooltip.tooltip-container"
|
|
/>
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
</FloatingPortal>
|
|
)}
|
|
</>
|
|
);
|
|
},
|
|
);
|