@blueprintjs/core
Version:
Core styles & components
284 lines (259 loc) • 13.2 kB
text/typescript
/* !
* (c) Copyright 2026 Palantir Technologies Inc. All rights reserved.
*/
import type { Placement, Boundary as PopperBoundary } from "@popperjs/core";
import * as Errors from "../../common/errors";
import { isNodeEnv } from "../../common/utils";
import { POPOVER_ARROW_SVG_SIZE } from "../popover/popoverArrow";
import { positionToPlacement } from "../popover/popoverPlacementUtils";
import { type PopoverPosition } from "../popover/popoverPosition";
import type { PopoverProps } from "../popover/popoverProps";
import type { DefaultPopoverTargetHTMLProps, PopperModifierOverrides } from "../popover/popoverSharedProps";
import type { MiddlewareConfig, PopoverNextBoundary, PopoverNextPlacement } from "./middlewareTypes";
import type { PopoverNextProps } from "./popoverNextProps";
/**
* Converts Popper.js v2 `modifiers` (used by `Popover`) to a Floating UI `MiddlewareConfig` (used by `PopoverNext`).
*
* The `modifiers` prop is not supported in `PopoverNext`; use the `middleware` prop instead.
*
* Modifier → middleware mappings:
* - `flip` → `flip`
* - `preventOverflow` → `shift` (Floating UI's equivalent "keep within boundary" concept)
* - `offset` → `offset` (tuple `[skidding, distance]` is converted to `{ crossAxis, mainAxis }`)
* - `arrow` → `arrow`
* - `hide` → `hide`
* - `computeStyles`, `eventListeners`, `popperOffsets` are not mapped (handled internally by Floating UI)
*
* **Note on offset:** If the Popper.js `offset` option is a function, it cannot be automatically
* converted and will be omitted with a console warning. Migrate it manually to a
* `{ mainAxis, crossAxis }` object in the `middleware` prop.
*
* @example
* // Before (Popover)
* <Popover modifiers={{ flip: { options: { padding: 8 } }, preventOverflow: { options: { padding: 4 } } }} />
*
* // After (PopoverNext)
* <PopoverNext middleware={popperModifiersToNextMiddleware({ flip: { options: { padding: 8 } }, preventOverflow: { options: { padding: 4 } } })} />
*/
export function popperModifiersToNextMiddleware(modifiers: PopperModifierOverrides): MiddlewareConfig {
const middleware: MiddlewareConfig = {};
if (modifiers.flip && modifiers.flip.enabled !== false) {
const { options } = modifiers.flip;
middleware.flip = {
...(options?.boundary != null ? { boundary: popperBoundaryToNextBoundary(options.boundary) } : {}),
...(options?.rootBoundary != null ? { rootBoundary: options.rootBoundary } : {}),
...(options?.padding != null ? { padding: options.padding } : {}),
...(options?.fallbackPlacements != null
? { fallbackPlacements: options.fallbackPlacements as PopoverNextPlacement[] }
: {}),
...(options?.flipVariations != null ? { flipAlignment: options.flipVariations } : {}),
...(options?.mainAxis != null ? { mainAxis: options.mainAxis } : {}),
...(options?.altAxis != null ? { crossAxis: options.altAxis } : {}),
};
}
if (modifiers.preventOverflow && modifiers.preventOverflow.enabled !== false) {
const { options } = modifiers.preventOverflow;
middleware.shift = {
...(options?.boundary != null ? { boundary: popperBoundaryToNextBoundary(options.boundary) } : {}),
...(options?.rootBoundary != null ? { rootBoundary: options.rootBoundary } : {}),
...(options?.padding != null ? { padding: options.padding } : {}),
...(options?.mainAxis != null ? { mainAxis: options.mainAxis } : {}),
...(options?.altAxis != null ? { crossAxis: options.altAxis } : {}),
};
}
if (modifiers.offset && modifiers.offset.enabled !== false) {
const { options } = modifiers.offset;
if (options?.offset != null) {
if (typeof options.offset === "function") {
console.warn(
"popperModifiersToNextMiddleware: The Popper.js `offset` function cannot be automatically " +
"converted to a Floating UI middleware config. Migrate it manually to a " +
"`{ mainAxis, crossAxis }` object in the `middleware` prop.",
);
} else {
const [skidding, distance] = options.offset;
middleware.offset = {
...(skidding != null ? { crossAxis: skidding } : {}),
...(distance != null ? { mainAxis: distance } : {}),
};
}
} else {
// Legacy `Popover` always populated `options.offset` with a default of
// `[0, POPOVER_ARROW_SVG_SIZE / 2]`, so `{ offset: { enabled: true } }` (a common pattern
// for forcing the offset on when the arrow is disabled) implicitly produced a 15px main-axis
// gap. Preserve that behavior here so the visual gap is not silently dropped.
middleware.offset = { mainAxis: POPOVER_ARROW_SVG_SIZE / 2 };
}
}
if (modifiers.arrow && modifiers.arrow.enabled !== false) {
const { options } = modifiers.arrow;
// Popper.js arrow element can be HTMLElement | string | null; string selectors are not supported by Floating UI.
if (options?.element != null && typeof options.element !== "string") {
middleware.arrow = {
element: options.element,
...(options.padding != null && typeof options.padding !== "function"
? { padding: options.padding }
: {}),
};
}
}
if (modifiers.hide && modifiers.hide.enabled !== false) {
middleware.hide = {};
}
return middleware;
}
/**
* Converts a partial legacy `PopoverProps` bag into a partial `PopoverNextProps` bag suitable
* for spreading onto `PopoverNext`. Preserves legacy default behavior where it differs from
* `PopoverNext`'s defaults (`shouldReturnFocusOnClose`).
*
* Transformations:
* - `placement` ?? `position` → `placement`, mirroring legacy `Popover`'s resolution
* (`placement ?? positionToPlacement(position)`). When `placement` is defined it always wins.
* - `modifiers` → `middleware` (via {@link popperModifiersToNextMiddleware}).
* - `minimal: true` → `animation: "minimal"` and `arrow: false` (legacy `minimal` disables the arrow).
* - `boundary: "clippingParents"` → `"clippingAncestors"` (the Floating UI equivalent).
*
* Dropped (with dev-only `console.warn`):
* - `modifiersCustom` — no Floating UI equivalent; migrate manually to `middleware`.
* - `portalStopPropagationEvents` — already deprecated and non-functional in React 17+.
*
* Intended for use inside Blueprint components that wrap `Popover` internally and pass
* through a `popoverProps` prop, so they can swap to `PopoverNext` without changing their public API.
*/
export function popoverPropsToNextProps<T extends DefaultPopoverTargetHTMLProps>(
props: Partial<PopoverProps<T>>,
): Partial<PopoverNextProps<T>> {
// Pull out every prop whose legacy type is structurally incompatible with its
// PopoverNext counterpart. After this destructure, `rest` contains only fields that
// share an identical type between Popover and PopoverNext, so the spread below is
// sound without any blanket cast.
const {
boundary,
minimal,
modifiers,
modifiersCustom,
onClose,
placement,
popoverRef,
// eslint-disable-next-line @typescript-eslint/no-deprecated
portalStopPropagationEvents,
position,
shouldReturnFocusOnClose,
...rest
} = props;
if (!isNodeEnv("production")) {
if (modifiersCustom !== undefined) {
console.warn(
"[Blueprint] popoverPropsToNextProps: `modifiersCustom` has no equivalent in PopoverNext and will be dropped. " +
"Migrate to the `middleware` prop manually.",
);
}
if (portalStopPropagationEvents !== undefined) {
console.warn(
"[Blueprint] popoverPropsToNextProps: `portalStopPropagationEvents` has no equivalent in PopoverNext and will be dropped.",
);
}
if (placement !== undefined && position !== undefined) {
console.warn(Errors.POPOVER_WARN_PLACEMENT_AND_POSITION_MUTEX);
}
}
const nextProps: Partial<PopoverNextProps<T>> = { ...rest };
if (boundary !== undefined) {
nextProps.boundary = popperBoundaryToNextBoundary(boundary);
}
// Mirror legacy `Popover`'s `placement ?? positionToPlacement(position)` rule: an explicit
// `placement` always wins, even `"auto*"` (which maps to `undefined` = autoPlacement default).
if (placement !== undefined) {
nextProps.placement = popoverPlacementToNextPlacement(placement);
} else if (position !== undefined) {
nextProps.placement = popoverPositionToNextPlacement(position);
}
if (modifiers !== undefined) {
nextProps.middleware = popperModifiersToNextMiddleware(modifiers);
}
if (minimal === true) {
nextProps.animation ??= "minimal";
nextProps.arrow ??= false;
}
if (onClose !== undefined) {
// Legacy `onClose` requires a non-undefined `event`; PopoverNext's signature allows
// `event` to be undefined. Wrap to honor the legacy contract — invoke the legacy
// handler only when an event is actually present.
nextProps.onClose = event => {
if (event !== undefined) {
onClose(event);
}
};
}
if (popoverRef !== undefined) {
// Legacy `popoverRef` is `React.Ref<HTMLElement>`; PopoverNext's is `React.Ref<HTMLDivElement>`.
// `RefObject<T>` is invariant in `T` (`.current` is mutable, so `T` appears in both read
// and write positions), so `RefObject<HTMLElement>` is not assignable to
// `RefObject<HTMLDivElement>` even though `HTMLDivElement` is a subtype of `HTMLElement`.
// In practice the underlying DOM node is a `<div>` (the `Classes.POPOVER` element), so a
// ref slot typed for the wider `HTMLElement` safely receives it. Cast narrowly here so
// the unsoundness stays scoped to this one prop instead of the whole bag.
nextProps.popoverRef = popoverRef as React.Ref<HTMLDivElement>;
}
// Legacy default for `shouldReturnFocusOnClose` is `false`; PopoverNext's is `true`.
nextProps.shouldReturnFocusOnClose = shouldReturnFocusOnClose ?? false;
return nextProps;
}
/**
* Converts a Popper.js `Boundary` value to a Floating UI `PopoverNextBoundary` value.
*
* The two systems use different names for the "all clipping ancestors" sentinel:
* - Popper.js: `"clippingParents"`
* - Floating UI: `"clippingAncestors"`
*
* Element / `Element[]` values pass through unchanged.
*/
export function popperBoundaryToNextBoundary(boundary: PopperBoundary): PopoverNextBoundary {
return boundary === "clippingParents" ? "clippingAncestors" : boundary;
}
/**
* Converts a Popper.js `Placement` value to a `PopoverNextPlacement` value for use with `PopoverNext`.
*
* `"auto"`, `"auto-start"`, and `"auto-end"` have no direct equivalent in Floating UI — they return
* `undefined`, which causes `PopoverNext` to use its default automatic placement behavior.
* All other values pass through unchanged (the residual literal union is identical to `PopoverNextPlacement`).
*
* @example
* // Before (Popover)
* <Popover placement="top-start" />
*
* // After (PopoverNext)
* <PopoverNext placement={popoverPlacementToNextPlacement("top-start")} />
*/
export function popoverPlacementToNextPlacement(placement: Placement): PopoverNextPlacement | undefined {
switch (placement) {
case "auto":
case "auto-start":
case "auto-end":
// PopoverNext uses autoPlacement middleware by default when placement is undefined.
return undefined;
default:
return placement;
}
}
/**
* Converts a legacy `PopoverPosition` value to a `PopoverNextPlacement` value for use with `PopoverNext`.
*
* The `position` prop is not supported in `PopoverNext`; use the `placement` prop instead.
* `"auto"`, `"auto-start"`, and `"auto-end"` have no direct equivalent — they return `undefined`,
* which causes `PopoverNext` to use its default automatic placement behavior.
*
* @example
* // Before (Popover)
* <Popover position={PopoverPosition.TOP_LEFT} />
*
* // After (PopoverNext)
* <PopoverNext placement={popoverPositionToNextPlacement(PopoverPosition.TOP_LEFT)} />
*/
export function popoverPositionToNextPlacement(position: PopoverPosition): PopoverNextPlacement | undefined {
// `positionToPlacement` translates PopoverPosition's `"top-left"`/`"bottom-right"` forms to
// Popper's `"top-start"`/`"bottom-end"` forms, and passes `"auto"`/`"auto-start"`/`"auto-end"`
// through unchanged. `popoverPlacementToNextPlacement` then filters out the auto* values.
return popoverPlacementToNextPlacement(positionToPlacement(position));
}