@redocly/theme 0.64.0-next.6 → 0.65.0-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -38,11 +38,18 @@ const react_1 = __importStar(require("react"));
38
38
  const styled_components_1 = __importStar(require("styled-components"));
39
39
  const hooks_1 = require("../../core/hooks");
40
40
  const Portal_1 = require("../../components/Portal/Portal");
41
- function TooltipComponent({ children, isOpen, tip, withArrow = true, placement = 'top', className = 'default', width, dataTestId, disabled = false, arrowPosition = 'center', onClick, }) {
41
+ function TooltipComponent({ children, isOpen, tip, withArrow = true, placement = 'top', fallbackPlacements, className = 'default', width, dataTestId, disabled = false, arrowPosition = 'center', onClick, }) {
42
42
  const tooltipWrapperRef = (0, react_1.useRef)(null);
43
43
  const tooltipBodyRef = (0, react_1.useRef)(null);
44
44
  const { isOpened, handleOpen, handleClose } = (0, hooks_1.useControl)(isOpen);
45
45
  const anchorName = `--tooltip${(0, react_1.useId)().replace(/:/g, '')}`;
46
+ const { activePlacement, activeArrowPosition } = (0, hooks_1.useTooltipFallbackPlacement)({
47
+ isOpened,
48
+ placement,
49
+ arrowPosition,
50
+ fallbackPlacements,
51
+ tooltipBodyRef,
52
+ });
46
53
  (0, hooks_1.useOutsideClick)(isOpened ? [tooltipWrapperRef, tooltipBodyRef] : tooltipWrapperRef, handleClose);
47
54
  const isControlled = isOpen !== undefined;
48
55
  (0, react_1.useEffect)(() => {
@@ -70,7 +77,9 @@ function TooltipComponent({ children, isOpen, tip, withArrow = true, placement =
70
77
  return (react_1.default.createElement(TooltipWrapper, Object.assign({ ref: tooltipWrapperRef }, controllers, { className: `tooltip-${className}`, "data-component-name": "Tooltip/Tooltip", anchorName: anchorName }),
71
78
  children,
72
79
  isOpened && !disabled && (react_1.default.createElement(Portal_1.Portal, null,
73
- react_1.default.createElement(TooltipBody, { ref: tooltipBodyRef, "data-testid": dataTestId || (typeof tip === 'string' ? tip : ''), placement: placement, width: width, withArrow: withArrow, arrowPosition: arrowPosition, anchorName: anchorName }, tip)))));
80
+ react_1.default.createElement(TooltipBody, { ref: tooltipBodyRef, "data-testid": dataTestId || (typeof tip === 'string' ? tip : ''), placement: activePlacement, width: width, withArrow: withArrow, arrowPosition: activeArrowPosition === 'left' || activeArrowPosition === 'right'
81
+ ? activeArrowPosition
82
+ : 'center', anchorName: anchorName }, tip)))));
74
83
  }
75
84
  exports.Tooltip = (0, react_1.memo)(TooltipComponent);
76
85
  const PLACEMENTS = {
@@ -38,56 +38,37 @@ const react_1 = __importStar(require("react"));
38
38
  const styled_components_1 = __importStar(require("styled-components"));
39
39
  const hooks_1 = require("../../core/hooks");
40
40
  const Portal_1 = require("../../components/Portal/Portal");
41
- function TooltipComponent({ children, isOpen, tip, withArrow = true, placement = 'top', className = 'default', width, dataTestId, disabled = false, arrowPosition = 'center', onClick, }) {
42
- const ref = (0, react_1.useRef)(null);
41
+ const utils_1 = require("../../core/utils");
42
+ function TooltipComponent({ children, isOpen, tip, withArrow = true, placement = 'top', fallbackPlacements, className = 'default', width, dataTestId, disabled = false, arrowPosition = 'center', onClick, }) {
43
+ const wrapperRef = (0, react_1.useRef)(null);
44
+ const tooltipBodyRef = (0, react_1.useRef)(null);
43
45
  const { isOpened, handleOpen, handleClose } = (0, hooks_1.useControl)(isOpen);
44
46
  const [tooltipPosition, setTooltipPosition] = (0, react_1.useState)({ top: 0, left: 0 });
45
- (0, hooks_1.useOutsideClick)(ref, handleClose);
47
+ const [activePlacement, setActivePlacement] = (0, react_1.useState)(placement);
48
+ const activeArrowPosition = activePlacement === placement ? arrowPosition : 'center';
49
+ (0, hooks_1.useOutsideClick)(wrapperRef, handleClose);
46
50
  const isControlled = isOpen !== undefined;
47
51
  const updateTooltipPosition = (0, react_1.useCallback)(() => {
48
- if (isOpened && ref.current) {
49
- const rect = ref.current.getBoundingClientRect();
50
- let top = 0;
51
- let left = 0;
52
- switch (placement) {
53
- case 'top':
54
- top = rect.top;
55
- if (arrowPosition === 'left') {
56
- left = rect.left - 24;
57
- }
58
- else if (arrowPosition === 'right') {
59
- left = rect.right + 24;
60
- }
61
- else {
62
- left = rect.left + rect.width / 2;
63
- }
64
- break;
65
- case 'bottom':
66
- top = rect.bottom;
67
- if (arrowPosition === 'left') {
68
- left = rect.left - 24;
69
- }
70
- else if (arrowPosition === 'right') {
71
- left = rect.right + 24;
72
- }
73
- else {
74
- left = rect.left + rect.width / 2;
75
- }
76
- break;
77
- case 'left':
78
- top = rect.top + rect.height / 2;
79
- left = rect.left;
80
- break;
81
- case 'right':
82
- top = rect.top + rect.height / 2;
83
- left = rect.right;
84
- break;
85
- }
86
- setTooltipPosition({ top, left });
87
- }
88
- }, [isOpened, placement, arrowPosition]);
52
+ var _a, _b, _c, _d;
53
+ if (!isOpened || !wrapperRef.current)
54
+ return;
55
+ const triggerRect = wrapperRef.current.getBoundingClientRect();
56
+ const tooltipWidth = (_b = (_a = tooltipBodyRef.current) === null || _a === void 0 ? void 0 : _a.offsetWidth) !== null && _b !== void 0 ? _b : 0;
57
+ const tooltipHeight = (_d = (_c = tooltipBodyRef.current) === null || _c === void 0 ? void 0 : _c.offsetHeight) !== null && _d !== void 0 ? _d : 0;
58
+ const resolved = (0, utils_1.resolvePlacement)({
59
+ triggerRect,
60
+ tooltipWidth,
61
+ tooltipHeight,
62
+ placement,
63
+ arrowPosition,
64
+ fallbackPlacements,
65
+ });
66
+ const resolvedArrow = resolved === placement ? arrowPosition : 'center';
67
+ setTooltipPosition((0, utils_1.calcAnchorPoint)(triggerRect, resolved, resolvedArrow));
68
+ setActivePlacement(resolved);
69
+ }, [isOpened, placement, arrowPosition, fallbackPlacements]);
89
70
  (0, react_1.useLayoutEffect)(() => {
90
- if (isOpened && ref.current) {
71
+ if (isOpened && wrapperRef.current) {
91
72
  updateTooltipPosition();
92
73
  const handleScroll = () => updateTooltipPosition();
93
74
  const handleResize = () => updateTooltipPosition();
@@ -118,10 +99,10 @@ function TooltipComponent({ children, isOpen, tip, withArrow = true, placement =
118
99
  onFocus: handleOpen,
119
100
  onBlur: handleClose,
120
101
  };
121
- return (react_1.default.createElement(TooltipWrapper, Object.assign({ ref: ref }, controllers, { className: `tooltip-${className}`, "data-component-name": "Tooltip/Tooltip" }),
102
+ return (react_1.default.createElement(TooltipWrapper, Object.assign({ ref: wrapperRef }, controllers, { className: `tooltip-${className}`, "data-component-name": "Tooltip/Tooltip" }),
122
103
  children,
123
104
  isOpened && !disabled && (react_1.default.createElement(Portal_1.Portal, null,
124
- react_1.default.createElement(TooltipBody, { "data-testid": dataTestId || (typeof tip === 'string' ? tip : ''), placement: placement, width: width, withArrow: withArrow, arrowPosition: arrowPosition, style: {
105
+ react_1.default.createElement(TooltipBody, { ref: tooltipBodyRef, "data-testid": dataTestId || (typeof tip === 'string' ? tip : ''), placement: activePlacement, width: width, withArrow: withArrow, arrowPosition: activeArrowPosition, style: {
125
106
  position: 'fixed',
126
107
  top: tooltipPosition.top,
127
108
  left: tooltipPosition.left,
@@ -1,3 +1,3 @@
1
1
  import React from 'react';
2
- import { TooltipProps } from '../../core/types';
2
+ import type { TooltipProps } from '../../core/types';
3
3
  export declare const Tooltip: React.NamedExoticComponent<TooltipProps>;
@@ -38,13 +38,16 @@ const react_1 = __importStar(require("react"));
38
38
  const AnchorTooltip_1 = require("../../components/Tooltip/AnchorTooltip");
39
39
  const JsTooltip_1 = require("../../components/Tooltip/JsTooltip");
40
40
  const hooks_1 = require("../../core/hooks");
41
+ const utils_1 = require("../../core/utils");
41
42
  function TooltipComponent(props) {
43
+ var _a, _b;
42
44
  const { useAnchorPositioning } = (0, hooks_1.useThemeHooks)();
43
45
  const { isSupported } = useAnchorPositioning();
46
+ const fallbackPlacements = (_a = props.fallbackPlacements) !== null && _a !== void 0 ? _a : (0, utils_1.getDefaultFallbackPlacements)((_b = props.placement) !== null && _b !== void 0 ? _b : 'top');
44
47
  if (isSupported) {
45
- return react_1.default.createElement(AnchorTooltip_1.Tooltip, Object.assign({}, props, { arrowPosition: prepareArrowPosition(props.arrowPosition) }));
48
+ return (react_1.default.createElement(AnchorTooltip_1.Tooltip, Object.assign({}, props, { fallbackPlacements: fallbackPlacements, arrowPosition: prepareArrowPosition(props.arrowPosition) })));
46
49
  }
47
- return react_1.default.createElement(JsTooltip_1.Tooltip, Object.assign({}, props));
50
+ return react_1.default.createElement(JsTooltip_1.Tooltip, Object.assign({}, props, { fallbackPlacements: fallbackPlacements }));
48
51
  }
49
52
  exports.Tooltip = (0, react_1.memo)(TooltipComponent);
50
53
  const prepareArrowPosition = (arrowPosition) => {
@@ -54,3 +54,4 @@ export * from './use-is-truncated';
54
54
  export * from './use-toast';
55
55
  export * from './use-toast-logic';
56
56
  export * from './use-banner-telemetry';
57
+ export * from './use-tooltip-fallback-placement';
@@ -70,4 +70,5 @@ __exportStar(require("./use-is-truncated"), exports);
70
70
  __exportStar(require("./use-toast"), exports);
71
71
  __exportStar(require("./use-toast-logic"), exports);
72
72
  __exportStar(require("./use-banner-telemetry"), exports);
73
+ __exportStar(require("./use-tooltip-fallback-placement"), exports);
73
74
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,15 @@
1
+ import type { RefObject } from 'react';
2
+ import type { TooltipPlacement, TooltipProps } from '../../core/types';
3
+ type TooltipFallbackPlacementParams = {
4
+ isOpened: boolean;
5
+ placement: TooltipPlacement;
6
+ arrowPosition: TooltipProps['arrowPosition'];
7
+ fallbackPlacements: TooltipPlacement[] | undefined;
8
+ tooltipBodyRef: RefObject<HTMLElement | null>;
9
+ };
10
+ type TooltipFallbackPlacementResult = {
11
+ activePlacement: TooltipPlacement;
12
+ activeArrowPosition: TooltipProps['arrowPosition'];
13
+ };
14
+ export declare function useTooltipFallbackPlacement({ isOpened, placement, arrowPosition, fallbackPlacements, tooltipBodyRef, }: TooltipFallbackPlacementParams): TooltipFallbackPlacementResult;
15
+ export {};
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.useTooltipFallbackPlacement = useTooltipFallbackPlacement;
4
+ const react_1 = require("react");
5
+ function useTooltipFallbackPlacement({ isOpened, placement, arrowPosition, fallbackPlacements, tooltipBodyRef, }) {
6
+ const [activePlacement, setActivePlacement] = (0, react_1.useState)(placement);
7
+ const wasOpenRef = (0, react_1.useRef)(false);
8
+ const candidateIndexRef = (0, react_1.useRef)(0);
9
+ (0, react_1.useLayoutEffect)(() => {
10
+ if (!isOpened) {
11
+ wasOpenRef.current = false;
12
+ candidateIndexRef.current = 0;
13
+ return;
14
+ }
15
+ if (!wasOpenRef.current) {
16
+ wasOpenRef.current = true;
17
+ candidateIndexRef.current = 0;
18
+ if (activePlacement !== placement) {
19
+ setActivePlacement(placement);
20
+ return;
21
+ }
22
+ }
23
+ if (!tooltipBodyRef.current || !(fallbackPlacements === null || fallbackPlacements === void 0 ? void 0 : fallbackPlacements.length))
24
+ return;
25
+ const candidates = [placement, ...fallbackPlacements];
26
+ if (candidateIndexRef.current >= candidates.length)
27
+ return;
28
+ const rect = tooltipBodyRef.current.getBoundingClientRect();
29
+ const overflows = rect.left < 0 ||
30
+ rect.top < 0 ||
31
+ rect.right > window.innerWidth ||
32
+ rect.bottom > window.innerHeight;
33
+ if (!overflows)
34
+ return;
35
+ candidateIndexRef.current++;
36
+ if (candidateIndexRef.current < candidates.length) {
37
+ setActivePlacement(candidates[candidateIndexRef.current]);
38
+ }
39
+ else if (activePlacement !== placement) {
40
+ setActivePlacement(placement);
41
+ }
42
+ }, [isOpened, activePlacement, placement, fallbackPlacements, tooltipBodyRef]);
43
+ const activeArrowPosition = activePlacement === placement ? arrowPosition : 'center';
44
+ return { activePlacement, activeArrowPosition };
45
+ }
46
+ //# sourceMappingURL=use-tooltip-fallback-placement.js.map
@@ -1232,6 +1232,7 @@ const replay = (0, styled_components_1.css) `
1232
1232
  --replay-runtime-expression-color: rgba(54, 90, 249, 1); // @presenter Color
1233
1233
  --replay-runtime-expression-bg-color: rgba(54, 90, 249, 0.08); // @presenter Color
1234
1234
  --replay-operators-color: rgba(193, 142, 31, 1); // @presenter Color
1235
+ --replay-claude-icon-color: rgba(217, 119, 87, 1); // @presenter Color
1235
1236
 
1236
1237
  --replay-ai-gradient-soft: linear-gradient(62.6deg, rgba(113, 94, 254, 0.16) 0%, rgba(255, 92, 220, 0.16) 100%);
1237
1238
  --replay-ai-gradient-disabled: linear-gradient(62.6deg, rgba(113, 94, 254, 0.6) 0%, rgba(255, 92, 220, 0.6) 100%);
@@ -1,10 +1,12 @@
1
1
  import type { ReactNode } from 'react';
2
+ export type TooltipPlacement = 'top' | 'bottom' | 'left' | 'right';
2
3
  export type TooltipProps = {
3
4
  children?: ReactNode;
4
5
  tip: string | ReactNode;
5
6
  isOpen?: boolean;
6
7
  withArrow?: boolean;
7
- placement?: 'top' | 'bottom' | 'left' | 'right';
8
+ placement?: TooltipPlacement;
9
+ fallbackPlacements?: TooltipPlacement[];
8
10
  className?: string;
9
11
  width?: string;
10
12
  dataTestId?: string;
@@ -46,3 +46,4 @@ export * from './build-revision-url';
46
46
  export * from './content-segments';
47
47
  export * from './custom-catalog-options-casing';
48
48
  export * from './get-auto-dismiss-duration';
49
+ export * from './tooltip-placement';
@@ -62,4 +62,5 @@ __exportStar(require("./build-revision-url"), exports);
62
62
  __exportStar(require("./content-segments"), exports);
63
63
  __exportStar(require("./custom-catalog-options-casing"), exports);
64
64
  __exportStar(require("./get-auto-dismiss-duration"), exports);
65
+ __exportStar(require("./tooltip-placement"), exports);
65
66
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,32 @@
1
+ import type { TooltipPlacement, TooltipProps } from '../../core/types';
2
+ export declare function getDefaultFallbackPlacements(placement: TooltipPlacement): TooltipPlacement[];
3
+ export declare function calcAnchorPoint(triggerRect: DOMRect, placement: TooltipPlacement, arrowPosition: TooltipProps['arrowPosition']): {
4
+ top: number;
5
+ left: number;
6
+ };
7
+ type FitsInViewportParams = {
8
+ anchor: {
9
+ top: number;
10
+ left: number;
11
+ };
12
+ tooltipWidth: number;
13
+ tooltipHeight: number;
14
+ placement: TooltipPlacement;
15
+ arrowPosition: TooltipProps['arrowPosition'];
16
+ };
17
+ export declare function fitsInViewport({ anchor, tooltipWidth, tooltipHeight, placement, arrowPosition, }: FitsInViewportParams): boolean;
18
+ type ResolvePlacementParams = {
19
+ triggerRect: DOMRect;
20
+ tooltipWidth: number;
21
+ tooltipHeight: number;
22
+ placement: TooltipPlacement;
23
+ arrowPosition: TooltipProps['arrowPosition'];
24
+ fallbackPlacements: TooltipPlacement[] | undefined;
25
+ };
26
+ /**
27
+ * Given the trigger rect, tooltip dimensions, primary placement/arrow, and
28
+ * fallback list, returns the first placement that keeps the tooltip fully
29
+ * inside the viewport. Falls back to the primary when nothing fits.
30
+ */
31
+ export declare function resolvePlacement({ triggerRect, tooltipWidth, tooltipHeight, placement, arrowPosition, fallbackPlacements, }: ResolvePlacementParams): TooltipPlacement;
32
+ export {};
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getDefaultFallbackPlacements = getDefaultFallbackPlacements;
4
+ exports.calcAnchorPoint = calcAnchorPoint;
5
+ exports.fitsInViewport = fitsInViewport;
6
+ exports.resolvePlacement = resolvePlacement;
7
+ const PLACEMENT_MARGIN = 10;
8
+ const COUNTER_CLOCKWISE = ['top', 'left', 'bottom', 'right'];
9
+ function getDefaultFallbackPlacements(placement) {
10
+ const index = COUNTER_CLOCKWISE.indexOf(placement);
11
+ const result = [];
12
+ for (let i = 1; i < COUNTER_CLOCKWISE.length; i++) {
13
+ result.push(COUNTER_CLOCKWISE[(index + i) % COUNTER_CLOCKWISE.length]);
14
+ }
15
+ return result;
16
+ }
17
+ function calcAnchorPoint(triggerRect, placement, arrowPosition) {
18
+ const horizontalLeft = () => arrowPosition === 'left'
19
+ ? triggerRect.left - 24
20
+ : arrowPosition === 'right'
21
+ ? triggerRect.right + 24
22
+ : triggerRect.left + triggerRect.width / 2;
23
+ const verticalTop = () => triggerRect.top + triggerRect.height / 2;
24
+ switch (placement) {
25
+ case 'top':
26
+ return { top: triggerRect.top, left: horizontalLeft() };
27
+ case 'bottom':
28
+ return { top: triggerRect.bottom, left: horizontalLeft() };
29
+ case 'left':
30
+ return { top: verticalTop(), left: triggerRect.left };
31
+ case 'right':
32
+ return { top: verticalTop(), left: triggerRect.right };
33
+ }
34
+ }
35
+ function fitsInViewport({ anchor, tooltipWidth, tooltipHeight, placement, arrowPosition, }) {
36
+ const horizontalLeft = () => arrowPosition === 'left'
37
+ ? anchor.left
38
+ : arrowPosition === 'right'
39
+ ? anchor.left - tooltipWidth
40
+ : anchor.left - tooltipWidth / 2;
41
+ const verticalTop = () => anchor.top - tooltipHeight / 2;
42
+ let top;
43
+ let left;
44
+ switch (placement) {
45
+ case 'top':
46
+ top = anchor.top - tooltipHeight - PLACEMENT_MARGIN;
47
+ left = horizontalLeft();
48
+ break;
49
+ case 'bottom':
50
+ top = anchor.top + PLACEMENT_MARGIN;
51
+ left = horizontalLeft();
52
+ break;
53
+ case 'left':
54
+ top = verticalTop();
55
+ left = anchor.left - tooltipWidth - PLACEMENT_MARGIN;
56
+ break;
57
+ case 'right':
58
+ top = verticalTop();
59
+ left = anchor.left + PLACEMENT_MARGIN;
60
+ break;
61
+ }
62
+ return (top >= 0 &&
63
+ left >= 0 &&
64
+ left + tooltipWidth <= window.innerWidth &&
65
+ top + tooltipHeight <= window.innerHeight);
66
+ }
67
+ /**
68
+ * Given the trigger rect, tooltip dimensions, primary placement/arrow, and
69
+ * fallback list, returns the first placement that keeps the tooltip fully
70
+ * inside the viewport. Falls back to the primary when nothing fits.
71
+ */
72
+ function resolvePlacement({ triggerRect, tooltipWidth, tooltipHeight, placement, arrowPosition, fallbackPlacements, }) {
73
+ if (!(fallbackPlacements === null || fallbackPlacements === void 0 ? void 0 : fallbackPlacements.length) || tooltipWidth === 0 || tooltipHeight === 0) {
74
+ return placement;
75
+ }
76
+ const candidates = [placement, ...fallbackPlacements];
77
+ for (const candidate of candidates) {
78
+ const candidateArrow = candidate === placement ? arrowPosition : 'center';
79
+ const pos = calcAnchorPoint(triggerRect, candidate, candidateArrow);
80
+ if (fitsInViewport({
81
+ anchor: pos,
82
+ tooltipWidth,
83
+ tooltipHeight,
84
+ placement: candidate,
85
+ arrowPosition: candidateArrow,
86
+ })) {
87
+ return candidate;
88
+ }
89
+ }
90
+ return placement;
91
+ }
92
+ //# sourceMappingURL=tooltip-placement.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redocly/theme",
3
- "version": "0.64.0-next.6",
3
+ "version": "0.65.0-next.0",
4
4
  "description": "Shared UI components lib",
5
5
  "keywords": [
6
6
  "theme",
@@ -63,7 +63,7 @@
63
63
  "vitest": "4.0.10",
64
64
  "vitest-when": "0.6.2",
65
65
  "webpack": "5.105.2",
66
- "@redocly/realm-asyncapi-sdk": "0.10.0-next.3"
66
+ "@redocly/realm-asyncapi-sdk": "0.11.0-next.0"
67
67
  },
68
68
  "dependencies": {
69
69
  "@tanstack/react-query": "5.62.3",
@@ -4,7 +4,11 @@ import styled, { css } from 'styled-components';
4
4
  import type { JSX, PropsWithChildren } from 'react';
5
5
  import type { TooltipProps } from '@redocly/theme/core/types';
6
6
 
7
- import { useControl, useOutsideClick } from '@redocly/theme/core/hooks';
7
+ import {
8
+ useControl,
9
+ useOutsideClick,
10
+ useTooltipFallbackPlacement,
11
+ } from '@redocly/theme/core/hooks';
8
12
  import { Portal } from '@redocly/theme/components/Portal/Portal';
9
13
 
10
14
  type Props = Exclude<TooltipProps, 'arrowPosition'> & {
@@ -17,6 +21,7 @@ function TooltipComponent({
17
21
  tip,
18
22
  withArrow = true,
19
23
  placement = 'top',
24
+ fallbackPlacements,
20
25
  className = 'default',
21
26
  width,
22
27
  dataTestId,
@@ -29,6 +34,14 @@ function TooltipComponent({
29
34
  const { isOpened, handleOpen, handleClose } = useControl(isOpen);
30
35
  const anchorName = `--tooltip${useId().replace(/:/g, '')}`;
31
36
 
37
+ const { activePlacement, activeArrowPosition } = useTooltipFallbackPlacement({
38
+ isOpened,
39
+ placement,
40
+ arrowPosition,
41
+ fallbackPlacements,
42
+ tooltipBodyRef,
43
+ });
44
+
32
45
  useOutsideClick(isOpened ? [tooltipWrapperRef, tooltipBodyRef] : tooltipWrapperRef, handleClose);
33
46
 
34
47
  const isControlled = isOpen !== undefined;
@@ -71,10 +84,14 @@ function TooltipComponent({
71
84
  <TooltipBody
72
85
  ref={tooltipBodyRef}
73
86
  data-testid={dataTestId || (typeof tip === 'string' ? tip : '')}
74
- placement={placement}
87
+ placement={activePlacement}
75
88
  width={width}
76
89
  withArrow={withArrow}
77
- arrowPosition={arrowPosition}
90
+ arrowPosition={
91
+ activeArrowPosition === 'left' || activeArrowPosition === 'right'
92
+ ? activeArrowPosition
93
+ : 'center'
94
+ }
78
95
  anchorName={anchorName}
79
96
  >
80
97
  {tip}
@@ -6,6 +6,7 @@ import type { TooltipProps } from '@redocly/theme/core/types';
6
6
 
7
7
  import { useControl, useOutsideClick } from '@redocly/theme/core/hooks';
8
8
  import { Portal } from '@redocly/theme/components/Portal/Portal';
9
+ import { calcAnchorPoint, resolvePlacement } from '@redocly/theme/core/utils';
9
10
 
10
11
  function TooltipComponent({
11
12
  children,
@@ -13,6 +14,7 @@ function TooltipComponent({
13
14
  tip,
14
15
  withArrow = true,
15
16
  placement = 'top',
17
+ fallbackPlacements,
16
18
  className = 'default',
17
19
  width,
18
20
  dataTestId,
@@ -20,58 +22,41 @@ function TooltipComponent({
20
22
  arrowPosition = 'center',
21
23
  onClick,
22
24
  }: PropsWithChildren<TooltipProps>): JSX.Element {
23
- const ref = useRef<HTMLDivElement | null>(null);
25
+ const wrapperRef = useRef<HTMLDivElement | null>(null);
26
+ const tooltipBodyRef = useRef<HTMLSpanElement | null>(null);
24
27
  const { isOpened, handleOpen, handleClose } = useControl(isOpen);
25
28
  const [tooltipPosition, setTooltipPosition] = useState({ top: 0, left: 0 });
29
+ const [activePlacement, setActivePlacement] = useState(placement);
26
30
 
27
- useOutsideClick(ref, handleClose);
31
+ const activeArrowPosition = activePlacement === placement ? arrowPosition : 'center';
32
+
33
+ useOutsideClick(wrapperRef, handleClose);
28
34
 
29
35
  const isControlled = isOpen !== undefined;
30
36
 
31
- const updateTooltipPosition = useCallback(() => {
32
- if (isOpened && ref.current) {
33
- const rect = ref.current.getBoundingClientRect();
34
-
35
- let top = 0;
36
- let left = 0;
37
-
38
- switch (placement) {
39
- case 'top':
40
- top = rect.top;
41
- if (arrowPosition === 'left') {
42
- left = rect.left - 24;
43
- } else if (arrowPosition === 'right') {
44
- left = rect.right + 24;
45
- } else {
46
- left = rect.left + rect.width / 2;
47
- }
48
- break;
49
- case 'bottom':
50
- top = rect.bottom;
51
- if (arrowPosition === 'left') {
52
- left = rect.left - 24;
53
- } else if (arrowPosition === 'right') {
54
- left = rect.right + 24;
55
- } else {
56
- left = rect.left + rect.width / 2;
57
- }
58
- break;
59
- case 'left':
60
- top = rect.top + rect.height / 2;
61
- left = rect.left;
62
- break;
63
- case 'right':
64
- top = rect.top + rect.height / 2;
65
- left = rect.right;
66
- break;
67
- }
68
-
69
- setTooltipPosition({ top, left });
70
- }
71
- }, [isOpened, placement, arrowPosition]);
37
+ const updateTooltipPosition = useCallback((): void => {
38
+ if (!isOpened || !wrapperRef.current) return;
39
+
40
+ const triggerRect = wrapperRef.current.getBoundingClientRect();
41
+ const tooltipWidth = tooltipBodyRef.current?.offsetWidth ?? 0;
42
+ const tooltipHeight = tooltipBodyRef.current?.offsetHeight ?? 0;
43
+
44
+ const resolved = resolvePlacement({
45
+ triggerRect,
46
+ tooltipWidth,
47
+ tooltipHeight,
48
+ placement,
49
+ arrowPosition,
50
+ fallbackPlacements,
51
+ });
52
+ const resolvedArrow = resolved === placement ? arrowPosition : 'center';
53
+
54
+ setTooltipPosition(calcAnchorPoint(triggerRect, resolved, resolvedArrow));
55
+ setActivePlacement(resolved);
56
+ }, [isOpened, placement, arrowPosition, fallbackPlacements]);
72
57
 
73
58
  useLayoutEffect(() => {
74
- if (isOpened && ref.current) {
59
+ if (isOpened && wrapperRef.current) {
75
60
  updateTooltipPosition();
76
61
 
77
62
  const handleScroll = () => updateTooltipPosition();
@@ -109,7 +94,7 @@ function TooltipComponent({
109
94
 
110
95
  return (
111
96
  <TooltipWrapper
112
- ref={ref}
97
+ ref={wrapperRef}
113
98
  {...controllers}
114
99
  className={`tooltip-${className}`}
115
100
  data-component-name="Tooltip/Tooltip"
@@ -118,11 +103,12 @@ function TooltipComponent({
118
103
  {isOpened && !disabled && (
119
104
  <Portal>
120
105
  <TooltipBody
106
+ ref={tooltipBodyRef}
121
107
  data-testid={dataTestId || (typeof tip === 'string' ? tip : '')}
122
- placement={placement}
108
+ placement={activePlacement}
123
109
  width={width}
124
110
  withArrow={withArrow}
125
- arrowPosition={arrowPosition}
111
+ arrowPosition={activeArrowPosition}
126
112
  style={{
127
113
  position: 'fixed',
128
114
  top: tooltipPosition.top,
@@ -255,7 +241,9 @@ const TooltipWrapper = styled.div`
255
241
  display: flex;
256
242
  `;
257
243
  const TooltipBody = styled.span<
258
- Pick<Required<TooltipProps>, 'placement' | 'withArrow' | 'arrowPosition'> & { width?: string }
244
+ Pick<Required<TooltipProps>, 'placement' | 'withArrow' | 'arrowPosition'> & {
245
+ width?: string;
246
+ }
259
247
  >`
260
248
  display: inline-block;
261
249
 
@@ -1,18 +1,29 @@
1
1
  import React, { memo } from 'react';
2
2
 
3
+ import type { TooltipProps } from '@redocly/theme/core/types';
4
+
3
5
  import { Tooltip as AnchorTooltip } from '@redocly/theme/components/Tooltip/AnchorTooltip';
4
6
  import { Tooltip as JsTooltip } from '@redocly/theme/components/Tooltip/JsTooltip';
5
- import { TooltipProps } from '@redocly/theme/core/types';
6
7
  import { useThemeHooks } from '@redocly/theme/core/hooks';
8
+ import { getDefaultFallbackPlacements } from '@redocly/theme/core/utils';
7
9
 
8
10
  function TooltipComponent(props: TooltipProps): React.ReactElement {
9
11
  const { useAnchorPositioning } = useThemeHooks();
10
12
  const { isSupported } = useAnchorPositioning();
11
13
 
14
+ const fallbackPlacements =
15
+ props.fallbackPlacements ?? getDefaultFallbackPlacements(props.placement ?? 'top');
16
+
12
17
  if (isSupported) {
13
- return <AnchorTooltip {...props} arrowPosition={prepareArrowPosition(props.arrowPosition)} />;
18
+ return (
19
+ <AnchorTooltip
20
+ {...props}
21
+ fallbackPlacements={fallbackPlacements}
22
+ arrowPosition={prepareArrowPosition(props.arrowPosition)}
23
+ />
24
+ );
14
25
  }
15
- return <JsTooltip {...props} />;
26
+ return <JsTooltip {...props} fallbackPlacements={fallbackPlacements} />;
16
27
  }
17
28
 
18
29
  export const Tooltip = memo<TooltipProps>(TooltipComponent);
@@ -26,3 +26,4 @@ export * from '../use-language-picker';
26
26
  export * from './use-element-size';
27
27
  export * from './use-time-ago';
28
28
  export * from './use-input-key-commands';
29
+ export * from '../use-tooltip-fallback-placement';
@@ -54,3 +54,4 @@ export * from './use-is-truncated';
54
54
  export * from './use-toast';
55
55
  export * from './use-toast-logic';
56
56
  export * from './use-banner-telemetry';
57
+ export * from './use-tooltip-fallback-placement';
@@ -0,0 +1,73 @@
1
+ import { useLayoutEffect, useRef, useState } from 'react';
2
+
3
+ import type { RefObject } from 'react';
4
+ import type { TooltipPlacement, TooltipProps } from '@redocly/theme/core/types';
5
+
6
+ type TooltipFallbackPlacementParams = {
7
+ isOpened: boolean;
8
+ placement: TooltipPlacement;
9
+ arrowPosition: TooltipProps['arrowPosition'];
10
+ fallbackPlacements: TooltipPlacement[] | undefined;
11
+ tooltipBodyRef: RefObject<HTMLElement | null>;
12
+ };
13
+
14
+ type TooltipFallbackPlacementResult = {
15
+ activePlacement: TooltipPlacement;
16
+ activeArrowPosition: TooltipProps['arrowPosition'];
17
+ };
18
+
19
+ export function useTooltipFallbackPlacement({
20
+ isOpened,
21
+ placement,
22
+ arrowPosition,
23
+ fallbackPlacements,
24
+ tooltipBodyRef,
25
+ }: TooltipFallbackPlacementParams): TooltipFallbackPlacementResult {
26
+ const [activePlacement, setActivePlacement] = useState<TooltipPlacement>(placement);
27
+ const wasOpenRef = useRef(false);
28
+ const candidateIndexRef = useRef(0);
29
+
30
+ useLayoutEffect(() => {
31
+ if (!isOpened) {
32
+ wasOpenRef.current = false;
33
+ candidateIndexRef.current = 0;
34
+ return;
35
+ }
36
+
37
+ if (!wasOpenRef.current) {
38
+ wasOpenRef.current = true;
39
+ candidateIndexRef.current = 0;
40
+ if (activePlacement !== placement) {
41
+ setActivePlacement(placement);
42
+ return;
43
+ }
44
+ }
45
+
46
+ if (!tooltipBodyRef.current || !fallbackPlacements?.length) return;
47
+
48
+ const candidates: TooltipPlacement[] = [placement, ...fallbackPlacements];
49
+
50
+ if (candidateIndexRef.current >= candidates.length) return;
51
+
52
+ const rect = tooltipBodyRef.current.getBoundingClientRect();
53
+ const overflows =
54
+ rect.left < 0 ||
55
+ rect.top < 0 ||
56
+ rect.right > window.innerWidth ||
57
+ rect.bottom > window.innerHeight;
58
+
59
+ if (!overflows) return;
60
+
61
+ candidateIndexRef.current++;
62
+
63
+ if (candidateIndexRef.current < candidates.length) {
64
+ setActivePlacement(candidates[candidateIndexRef.current]);
65
+ } else if (activePlacement !== placement) {
66
+ setActivePlacement(placement);
67
+ }
68
+ }, [isOpened, activePlacement, placement, fallbackPlacements, tooltipBodyRef]);
69
+
70
+ const activeArrowPosition = activePlacement === placement ? arrowPosition : 'center';
71
+
72
+ return { activePlacement, activeArrowPosition };
73
+ }
@@ -1253,6 +1253,7 @@ const replay = css`
1253
1253
  --replay-runtime-expression-color: rgba(54, 90, 249, 1); // @presenter Color
1254
1254
  --replay-runtime-expression-bg-color: rgba(54, 90, 249, 0.08); // @presenter Color
1255
1255
  --replay-operators-color: rgba(193, 142, 31, 1); // @presenter Color
1256
+ --replay-claude-icon-color: rgba(217, 119, 87, 1); // @presenter Color
1256
1257
 
1257
1258
  --replay-ai-gradient-soft: linear-gradient(62.6deg, rgba(113, 94, 254, 0.16) 0%, rgba(255, 92, 220, 0.16) 100%);
1258
1259
  --replay-ai-gradient-disabled: linear-gradient(62.6deg, rgba(113, 94, 254, 0.6) 0%, rgba(255, 92, 220, 0.6) 100%);
@@ -1,11 +1,14 @@
1
1
  import type { ReactNode } from 'react';
2
2
 
3
+ export type TooltipPlacement = 'top' | 'bottom' | 'left' | 'right';
4
+
3
5
  export type TooltipProps = {
4
6
  children?: ReactNode;
5
7
  tip: string | ReactNode;
6
8
  isOpen?: boolean;
7
9
  withArrow?: boolean;
8
- placement?: 'top' | 'bottom' | 'left' | 'right';
10
+ placement?: TooltipPlacement;
11
+ fallbackPlacements?: TooltipPlacement[];
9
12
  className?: string;
10
13
  width?: string;
11
14
  dataTestId?: string;
@@ -46,3 +46,4 @@ export * from './build-revision-url';
46
46
  export * from './content-segments';
47
47
  export * from './custom-catalog-options-casing';
48
48
  export * from './get-auto-dismiss-duration';
49
+ export * from './tooltip-placement';
@@ -0,0 +1,141 @@
1
+ import type { TooltipPlacement, TooltipProps } from '@redocly/theme/core/types';
2
+
3
+ const PLACEMENT_MARGIN = 10;
4
+ const COUNTER_CLOCKWISE: TooltipPlacement[] = ['top', 'left', 'bottom', 'right'];
5
+
6
+ export function getDefaultFallbackPlacements(placement: TooltipPlacement): TooltipPlacement[] {
7
+ const index = COUNTER_CLOCKWISE.indexOf(placement);
8
+ const result: TooltipPlacement[] = [];
9
+ for (let i = 1; i < COUNTER_CLOCKWISE.length; i++) {
10
+ result.push(COUNTER_CLOCKWISE[(index + i) % COUNTER_CLOCKWISE.length]);
11
+ }
12
+ return result;
13
+ }
14
+
15
+ export function calcAnchorPoint(
16
+ triggerRect: DOMRect,
17
+ placement: TooltipPlacement,
18
+ arrowPosition: TooltipProps['arrowPosition'],
19
+ ): { top: number; left: number } {
20
+ const horizontalLeft = (): number =>
21
+ arrowPosition === 'left'
22
+ ? triggerRect.left - 24
23
+ : arrowPosition === 'right'
24
+ ? triggerRect.right + 24
25
+ : triggerRect.left + triggerRect.width / 2;
26
+
27
+ const verticalTop = (): number => triggerRect.top + triggerRect.height / 2;
28
+
29
+ switch (placement) {
30
+ case 'top':
31
+ return { top: triggerRect.top, left: horizontalLeft() };
32
+ case 'bottom':
33
+ return { top: triggerRect.bottom, left: horizontalLeft() };
34
+ case 'left':
35
+ return { top: verticalTop(), left: triggerRect.left };
36
+ case 'right':
37
+ return { top: verticalTop(), left: triggerRect.right };
38
+ }
39
+ }
40
+
41
+ type FitsInViewportParams = {
42
+ anchor: { top: number; left: number };
43
+ tooltipWidth: number;
44
+ tooltipHeight: number;
45
+ placement: TooltipPlacement;
46
+ arrowPosition: TooltipProps['arrowPosition'];
47
+ };
48
+
49
+ export function fitsInViewport({
50
+ anchor,
51
+ tooltipWidth,
52
+ tooltipHeight,
53
+ placement,
54
+ arrowPosition,
55
+ }: FitsInViewportParams): boolean {
56
+ const horizontalLeft = (): number =>
57
+ arrowPosition === 'left'
58
+ ? anchor.left
59
+ : arrowPosition === 'right'
60
+ ? anchor.left - tooltipWidth
61
+ : anchor.left - tooltipWidth / 2;
62
+
63
+ const verticalTop = (): number => anchor.top - tooltipHeight / 2;
64
+
65
+ let top: number;
66
+ let left: number;
67
+
68
+ switch (placement) {
69
+ case 'top':
70
+ top = anchor.top - tooltipHeight - PLACEMENT_MARGIN;
71
+ left = horizontalLeft();
72
+ break;
73
+ case 'bottom':
74
+ top = anchor.top + PLACEMENT_MARGIN;
75
+ left = horizontalLeft();
76
+ break;
77
+ case 'left':
78
+ top = verticalTop();
79
+ left = anchor.left - tooltipWidth - PLACEMENT_MARGIN;
80
+ break;
81
+ case 'right':
82
+ top = verticalTop();
83
+ left = anchor.left + PLACEMENT_MARGIN;
84
+ break;
85
+ }
86
+
87
+ return (
88
+ top >= 0 &&
89
+ left >= 0 &&
90
+ left + tooltipWidth <= window.innerWidth &&
91
+ top + tooltipHeight <= window.innerHeight
92
+ );
93
+ }
94
+
95
+ type ResolvePlacementParams = {
96
+ triggerRect: DOMRect;
97
+ tooltipWidth: number;
98
+ tooltipHeight: number;
99
+ placement: TooltipPlacement;
100
+ arrowPosition: TooltipProps['arrowPosition'];
101
+ fallbackPlacements: TooltipPlacement[] | undefined;
102
+ };
103
+
104
+ /**
105
+ * Given the trigger rect, tooltip dimensions, primary placement/arrow, and
106
+ * fallback list, returns the first placement that keeps the tooltip fully
107
+ * inside the viewport. Falls back to the primary when nothing fits.
108
+ */
109
+ export function resolvePlacement({
110
+ triggerRect,
111
+ tooltipWidth,
112
+ tooltipHeight,
113
+ placement,
114
+ arrowPosition,
115
+ fallbackPlacements,
116
+ }: ResolvePlacementParams): TooltipPlacement {
117
+ if (!fallbackPlacements?.length || tooltipWidth === 0 || tooltipHeight === 0) {
118
+ return placement;
119
+ }
120
+
121
+ const candidates: TooltipPlacement[] = [placement, ...fallbackPlacements];
122
+
123
+ for (const candidate of candidates) {
124
+ const candidateArrow = candidate === placement ? arrowPosition : 'center';
125
+ const pos = calcAnchorPoint(triggerRect, candidate, candidateArrow);
126
+
127
+ if (
128
+ fitsInViewport({
129
+ anchor: pos,
130
+ tooltipWidth,
131
+ tooltipHeight,
132
+ placement: candidate,
133
+ arrowPosition: candidateArrow,
134
+ })
135
+ ) {
136
+ return candidate;
137
+ }
138
+ }
139
+
140
+ return placement;
141
+ }