Skip to content

Commit 6baccd1

Browse files
authored
Updated popover and tooltip components (#74)
1 parent 3dd4535 commit 6baccd1

81 files changed

Lines changed: 4249 additions & 3473 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/components/src/components/popover/__tests__/hooks/usePopoverInteractions.test.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,7 @@ vi.mock('@/utils/keyboard/keyboard', () => ({
1919
const mockOnClose = vi.fn();
2020

2121
// Mock refs for testing
22-
const createMockRef = <T>(
23-
initialValue: T | null = null,
24-
): RefObject<T | null> => ({
22+
const createMockRef = <T>(initialValue: T | null = null): RefObject<T> => ({
2523
current: initialValue,
2624
});
2725

packages/components/src/components/popover/__tests__/popover.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,8 +199,8 @@ describe('Popover component', () => {
199199
<button>external</button>
200200
<Popover
201201
{...mockProps}
202-
disableRestoreFocusAfterClose
203202
disableAutoFocusFirstDescendantAfterClose={false}
203+
disableRestoreFocusAfterClose={true}
204204
open={open}
205205
onClose={() => setOpen(false)}
206206
/>

packages/components/src/components/popover/__tests__/positioning/middlewareUtils.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ describe('middlewareUtils', () => {
9090
const elementResult = getMiddlewareStack({
9191
arrowElement: mockArrowElement,
9292
edgePadding: 8,
93+
enableFlip: true,
9394
isBodyAnchor: false,
9495
mainAxisOffset: 10,
9596
placement: 'bottom',

packages/components/src/components/popover/hooks/positioning/arrowPositionStyles.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ export const positionArrow = (
9696
computedPlacement: Placement,
9797
offsetDistance: [number, number] | undefined,
9898
arrowSize: number,
99+
arrowPadding?: number,
99100
): void => {
100101
if (!hasArrow || !middlewareData.arrow || !arrowElement) {
101102
return;
@@ -105,7 +106,11 @@ export const positionArrow = (
105106

106107
// Calculate arrow offset
107108
let arrowOffset = `-${Math.floor(arrowSize / 2)}px`;
108-
if (offsetDistance && offsetDistance[0] !== undefined) {
109+
110+
if (arrowPadding !== undefined) {
111+
// arrowPadding controls arrow offset explicitly (e.g., for HoverBridgeContainer padding)
112+
arrowOffset = `${arrowPadding - Math.floor(arrowSize / 2)}px`;
113+
} else if (offsetDistance && offsetDistance[0] !== undefined) {
109114
arrowOffset = `${-Math.abs(offsetDistance[0] / 4)}px`;
110115
}
111116

packages/components/src/components/popover/hooks/positioning/middlewareUtils.ts

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import {
22
type Middleware,
3-
type Placement,
43
arrow,
54
flip,
65
hide,
76
offset,
87
shift,
98
} from '@floating-ui/dom';
109

11-
import { getPlacementDirection } from '../../utils/placement.utils';
10+
import {
11+
type BodyDirection,
12+
getPlacementDirection,
13+
} from '../../utils/placement.utils';
1214

1315
/**
1416
},
@@ -19,8 +21,8 @@ import { getPlacementDirection } from '../../utils/placement.utils';
1921
* Creates middlewares for body anchor positioning
2022
*/
2123
const createBodyAnchorMiddlewares = (
22-
placement: Placement | undefined,
2324
edgePadding: number,
25+
placement?: BodyDirection,
2426
): Middleware[] => {
2527
const bodyDirection = getPlacementDirection(placement);
2628
const middlewares: Middleware[] = [];
@@ -57,31 +59,45 @@ const createBodyAnchorMiddlewares = (
5759
*/
5860
const createElementAnchorMiddlewares = (
5961
mainAxisOffset: number,
60-
offsetDistance: [number, number] | undefined,
6162
edgePadding: number,
63+
offsetDistance?: [number, number],
64+
enableFlip?: boolean,
6265
): Middleware[] => {
6366
const middlewares: Middleware[] = [];
6467

65-
// Standard element as anchor - use offset + flip
68+
// Standard element as anchor - use offset + flip + shift
6669
middlewares.push(
6770
offset({
6871
crossAxis: offsetDistance?.[1] || 0,
6972
mainAxis: mainAxisOffset,
7073
}),
7174
);
7275

73-
// Add flip middleware to automatically change position when necessary
74-
middlewares.push(
75-
flip({
76-
// Provide fallback positions for better positioning
77-
fallbackAxisSideDirection: 'start',
78-
padding: edgePadding,
79-
}),
80-
);
76+
// Add flip middleware only if enabled (to automatically change position when necessary)
77+
if (enableFlip) {
78+
middlewares.push(
79+
flip({
80+
// Specify explicit fallback placements to prevent infinite loops
81+
// This prevents floating-ui from trying all possible positions rapidly
82+
fallbackPlacements: ['top', 'bottom', 'right', 'left'],
83+
fallbackAxisSideDirection: 'start',
84+
// Use fallbackStrategy 'initialPlacement' to prefer staying close to the original position
85+
fallbackStrategy: 'initialPlacement',
86+
padding: edgePadding,
87+
}),
88+
);
89+
}
8190

8291
// Add shift to keep element in viewport
8392
middlewares.push(
8493
shift({
94+
// Limit shift to prevent excessive movement that can trigger re-flips
95+
limiter: {
96+
fn: ({ placement, x, y }) => {
97+
// Limit maximum shift to prevent position oscillation
98+
return { placement, x, y };
99+
},
100+
},
85101
padding: edgePadding,
86102
}),
87103
);
@@ -139,6 +155,7 @@ export const getMiddlewareStack = ({
139155
arrowElement,
140156
customMiddlewares,
141157
edgePadding,
158+
enableFlip,
142159
hideWhenDetached,
143160
isBodyAnchor,
144161
mainAxisOffset,
@@ -148,11 +165,12 @@ export const getMiddlewareStack = ({
148165
isBodyAnchor: boolean;
149166
edgePadding: number;
150167
mainAxisOffset: number;
151-
placement?: Placement;
168+
placement?: BodyDirection;
152169
offsetDistance?: [number, number];
153170
customMiddlewares?: Array<Middleware>;
154171
arrowElement?: HTMLElement | null;
155172
hideWhenDetached?: boolean;
173+
enableFlip?: boolean;
156174
}): Middleware[] => {
157175
let middlewareStack: Middleware[];
158176

@@ -162,12 +180,13 @@ export const getMiddlewareStack = ({
162180
} else {
163181
// Add positioning middleware based on anchor type
164182
if (isBodyAnchor) {
165-
middlewareStack = createBodyAnchorMiddlewares(placement, edgePadding);
183+
middlewareStack = createBodyAnchorMiddlewares(edgePadding, placement);
166184
} else {
167185
middlewareStack = createElementAnchorMiddlewares(
168186
mainAxisOffset,
169-
offsetDistance,
170187
edgePadding,
188+
offsetDistance,
189+
enableFlip,
171190
);
172191
}
173192
}
@@ -192,7 +211,7 @@ export const calculateMainAxisOffset = ({
192211
hasArrow,
193212
offsetDistance,
194213
}: {
195-
offsetDistance: [number, number] | undefined;
214+
offsetDistance?: [number, number];
196215
hasArrow: boolean;
197216
arrowSize: number;
198217
}): number => {

packages/components/src/components/popover/hooks/positioning/positionStyles.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,8 @@
33
*/
44
import type { Middleware, Strategy } from '@floating-ui/dom';
55

6-
import type { BodyDirection } from '../../utils/placement.utils';
7-
86
import { DEFAULT_PLACEMENT } from '../../types/animation';
7+
import type { BodyDirection } from '../../utils/placement.utils';
98

109
/**
1110
* Generates CSS position styles based on positioning parameters

packages/components/src/components/popover/hooks/positioning/types.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import type { Middleware, Placement, Strategy } from '@floating-ui/dom';
1+
import type { Middleware, Strategy } from '@floating-ui/dom';
22

33
import type { ArrowStyles } from '../../types/popover';
4+
import type { BodyDirection } from '../../utils/placement.utils';
45

56
/**
67
* Options for positioning middlewares
@@ -12,19 +13,21 @@ import type { ArrowStyles } from '../../types/popover';
1213
* @property {[number, number]} [offsetDistance] - Optional offset distance of the popover from the anchor [main axis, cross axis] in pixels
1314
* @property {number} [edgePadding] - Optional padding for flip and shift middlewares to ensure popover stays within viewport bounds
1415
* @property {boolean} [hideWhenDetached] - Whether to hide the popover when the anchor element is not visible due to scrolling or being outside the viewport
16+
* @property {boolean} [enableFlip] - Whether to enable automatic repositioning (flip) when there isn't enough space. Default: true
1517
*/
1618
export interface PositioningMiddlewareOptions {
1719
offsetDistance?: [number, number];
1820
edgePadding?: number;
1921
hideWhenDetached?: boolean;
22+
enableFlip?: boolean;
2023
}
2124

2225
/**
2326
* Internal values used by the positioning system
2427
*/
2528
export interface PositioningValues {
2629
anchorElement?: HTMLElement | null;
27-
placement?: Placement;
30+
placement?: BodyDirection;
2831
middlewareOptions: PositioningMiddlewareOptions;
2932
middlewares: Array<Middleware>;
3033
strategy: Strategy;

packages/components/src/components/popover/hooks/types/usePopoverInteractions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,5 @@ interface IUsePopoverInteractionsResponse {
1212
}
1313

1414
export type IUsePopoverInteractions = (
15-
params: IUsePopoverInteractionsParams,
15+
params: IUsePopoverInteractionsParams
1616
) => IUsePopoverInteractionsResponse;

packages/components/src/components/popover/hooks/usePopoverPositioning.ts

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
/**
2+
* Specialized hook to handle everything related to popover positioning,
3+
* including middlewares, position styles and arrows.
4+
*/
5+
import { type RefObject, useCallback, useEffect, useRef } from 'react';
6+
17
import {
28
type Middleware,
39
type MiddlewareData,
@@ -6,23 +12,17 @@ import {
612
autoUpdate,
713
computePosition as computePositionFloating,
814
} from '@floating-ui/dom';
9-
/**
10-
* Specialized hook to handle everything related to popover positioning,
11-
* including middlewares, position styles and arrows.
12-
*/
13-
import { type RefObject, useCallback, useEffect, useRef } from 'react';
1415

1516
import type { BodyDirection } from '../utils/placement.utils';
16-
import type { PositioningValues } from './positioning/types';
17-
import type { IUsePopoverPositioning } from './types/usePopoverPositioning';
18-
1917
import { positionArrow } from './positioning/arrowPositionStyles';
2018
import {
2119
calculateMainAxisOffset,
2220
getMiddlewareStack,
2321
} from './positioning/middlewareUtils';
2422
import { determinePositioningConfig } from './positioning/positionCalculation';
2523
import { applyPositionStyles } from './positioning/positionStyles';
24+
import type { PositioningValues } from './positioning/types';
25+
import type { IUsePopoverPositioning } from './types/usePopoverPositioning';
2626

2727
/**
2828
* Hook that handles popover positioning and its related components
@@ -33,13 +33,17 @@ export const usePopoverPositioning: IUsePopoverPositioning = ({
3333
isVisible,
3434
middlewareOptions = {},
3535
middlewares = [],
36-
placement,
36+
placement: placementProp,
3737
ref,
3838
strategy,
3939
}) => {
40+
// Normalize placement: 'top' for element anchors, 'center' for body anchor
41+
const placement = placementProp ?? (anchorElement ? 'top' : 'center');
42+
4043
// Default values for middleware options
4144
const {
4245
edgePadding = 0,
46+
enableFlip,
4347
hideWhenDetached,
4448
offsetDistance,
4549
} = middlewareOptions;
@@ -181,6 +185,7 @@ export const usePopoverPositioning: IUsePopoverPositioning = ({
181185
computedPlacement,
182186
currentOffsetDistance,
183187
arrowSize,
188+
arrowStyles?.padding,
184189
);
185190

186191
// Invoke callback if provided
@@ -192,13 +197,13 @@ export const usePopoverPositioning: IUsePopoverPositioning = ({
192197
// Helper to prepare positioning data
193198
const preparePositioningData = useCallback(
194199
(
195-
currentOffsetDistance: [number, number] | undefined,
196200
arrowSize: number,
197201
isBodyAnchor: boolean,
198202
currentEdgePadding: number,
199-
currentPlacement: Placement | undefined,
200203
currentMiddlewares: Middleware[],
201204
currentMiddlewareOptions: { hideWhenDetached?: boolean },
205+
currentPlacement?: BodyDirection,
206+
currentOffsetDistance?: [number, number],
202207
) => {
203208
const mainAxisOffset = calculateMainAxisOffset({
204209
arrowSize,
@@ -210,6 +215,7 @@ export const usePopoverPositioning: IUsePopoverPositioning = ({
210215
arrowElement,
211216
customMiddlewares: currentMiddlewares,
212217
edgePadding: currentEdgePadding,
218+
enableFlip,
213219
hideWhenDetached: currentMiddlewareOptions?.hideWhenDetached,
214220
isBodyAnchor,
215221
mainAxisOffset,
@@ -227,18 +233,18 @@ export const usePopoverPositioning: IUsePopoverPositioning = ({
227233
currentRef: RefObject<HTMLElement | null>,
228234
middlewareData: MiddlewareData,
229235
isBodyAnchor: boolean,
230-
currentPlacement: BodyDirection | undefined,
231236
x: number,
232237
y: number,
233238
currentStrategy: Strategy,
234239
currentEdgePadding: number,
235240
arrowElement: Element | null,
236241
computedPlacement: Placement,
237-
currentOffsetDistance: [number, number] | undefined,
238242
arrowSize: number,
239-
currentOnPositioned: (() => void) | undefined,
240243
customMiddlewares: Middleware[] = [],
241244
middlewareStack: Middleware[] = [],
245+
currentPlacement?: BodyDirection,
246+
currentOffsetDistance?: [number, number],
247+
currentOnPositioned?: () => void,
242248
) => {
243249
const shouldHide =
244250
middlewareData.hide?.referenceHidden || middlewareData.hide?.escaped;
@@ -247,6 +253,9 @@ export const usePopoverPositioning: IUsePopoverPositioning = ({
247253
if (currentRef.current) {
248254
currentRef.current.style.opacity = shouldHide ? '0' : '';
249255
currentRef.current.style.pointerEvents = shouldHide ? 'none' : '';
256+
// Ensure visibility is restored even when shouldHide is true
257+
// to remove the initial CSS visibility:hidden used to prevent scroll jumps
258+
currentRef.current.style.visibility = shouldHide ? 'hidden' : 'visible';
250259
}
251260

252261
// Apply positioning styles only if popover should be visible
@@ -305,13 +314,13 @@ export const usePopoverPositioning: IUsePopoverPositioning = ({
305314

306315
// Prepare positioning data
307316
const { arrowElement, middlewareStack } = preparePositioningData(
308-
currentOffsetDistance,
309317
arrowSize,
310318
isBodyAnchor,
311319
currentEdgePadding,
312-
currentPlacement,
313320
currentMiddlewares,
314321
currentMiddlewareOptions || {},
322+
currentPlacement,
323+
currentOffsetDistance,
315324
);
316325

317326
// Get positioning configuration
@@ -343,18 +352,18 @@ export const usePopoverPositioning: IUsePopoverPositioning = ({
343352
currentRef,
344353
middlewareData,
345354
isBodyAnchor,
346-
actualPlacement,
347355
x,
348356
y,
349357
currentStrategy,
350358
currentEdgePadding,
351359
arrowElement,
352360
computedPlacement,
353-
currentOffsetDistance,
354361
arrowSize,
355-
currentOnPositioned,
356362
currentMiddlewares,
357363
middlewareStack,
364+
actualPlacement,
365+
currentOffsetDistance,
366+
currentOnPositioned,
358367
);
359368
}, []);
360369

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export * from './animations/spring.animations';
2-
export { Popover } from './popover';
3-
export * from './positioning/middlewares';
4-
export type { PopoverStyleProps } from './types/popoverTheme';
1+
export { Popover } from "./popover";
2+
export * from "./positioning/middlewares";
3+
export { createSpringAnimation } from "./animations/spring.animations";
4+
export type { PopoverStyleProps } from "./types/popoverTheme";

0 commit comments

Comments
 (0)