diff --git a/change/@fluentui-react-positioning-2c04f2a8-30a8-43b4-9b96-36b24f1fce36.json b/change/@fluentui-react-positioning-2c04f2a8-30a8-43b4-9b96-36b24f1fce36.json new file mode 100644 index 00000000000000..5e523a9e1147fe --- /dev/null +++ b/change/@fluentui-react-positioning-2c04f2a8-30a8-43b4-9b96-36b24f1fce36.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: Upgrade to Floating UI v1", + "packageName": "@fluentui/react-positioning", + "email": "lingfangao@hotmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-menu/src/components/Menu/Menu.test.tsx b/packages/react-components/react-menu/src/components/Menu/Menu.test.tsx index 08711e03c42065..d8ab685555567c 100644 --- a/packages/react-components/react-menu/src/components/Menu/Menu.test.tsx +++ b/packages/react-components/react-menu/src/components/Menu/Menu.test.tsx @@ -39,7 +39,6 @@ describe('Menu', () => { beforeEach(() => { resetIdsForTests(); - jest.useRealTimers(); }); /** diff --git a/packages/react-components/react-positioning/etc/react-positioning.api.md b/packages/react-components/react-positioning/etc/react-positioning.api.md index 26d33702a46885..6743c6fe31e292 100644 --- a/packages/react-components/react-positioning/etc/react-positioning.api.md +++ b/packages/react-components/react-positioning/etc/react-positioning.api.md @@ -111,8 +111,8 @@ export function resolvePositioningShorthand(shorthand: PositioningShorthand | un // Warning: (ae-internal-missing-underscore) The name "usePositioning" should be prefixed with an underscore because the declaration is marked as @internal // -// @internal -export function usePositioning(options?: UsePopperOptions): { +// @internal (undocumented) +export function usePositioning(options: UsePositioningOptions): { targetRef: React_2.MutableRefObject; containerRef: React_2.MutableRefObject; arrowRef: React_2.MutableRefObject; diff --git a/packages/react-components/react-positioning/package.json b/packages/react-components/react-positioning/package.json index 0a004fe3cbd481..5a17c6d3c0e2e7 100644 --- a/packages/react-components/react-positioning/package.json +++ b/packages/react-components/react-positioning/package.json @@ -28,11 +28,11 @@ "@fluentui/scripts": "^1.0.0" }, "dependencies": { - "@griffel/react": "^1.3.0", + "@floating-ui/dom": "^1.0.0", "@fluentui/react-shared-contexts": "^9.0.0", "@fluentui/react-theme": "^9.0.0", "@fluentui/react-utilities": "^9.0.2", - "@popperjs/core": "~2.4.3", + "@griffel/react": "^1.3.0", "tslib": "^2.1.0" }, "peerDependencies": { diff --git a/packages/react-components/react-positioning/src/constants.ts b/packages/react-components/react-positioning/src/constants.ts new file mode 100644 index 00000000000000..9766a880b866fb --- /dev/null +++ b/packages/react-components/react-positioning/src/constants.ts @@ -0,0 +1,4 @@ +export const DATA_POSITIONING_INTERSECTING = 'data-popper-is-intersecting'; +export const DATA_POSITIONING_ESCAPED = 'data-popper-escaped'; +export const DATA_POSITIONING_HIDDEN = 'data-popper-reference-hidden'; +export const DATA_POSITIONING_PLACEMENT = 'data-popper-placement'; diff --git a/packages/react-components/react-positioning/src/isIntersectingModifier.ts b/packages/react-components/react-positioning/src/isIntersectingModifier.ts deleted file mode 100644 index 9f0fcc09938844..00000000000000 --- a/packages/react-components/react-positioning/src/isIntersectingModifier.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { detectOverflow, Modifier } from '@popperjs/core'; - -export const isIntersectingModifier: IsIntersectingModifier = { - name: 'is-intersecting-modifier', - enabled: true, - phase: 'main', - requires: ['preventOverflow'], - fn: ({ state, name }) => { - const popperRect = state.rects.popper; - const popperAltOverflow = detectOverflow(state, { altBoundary: true }); - - const isIntersectingTop = popperAltOverflow.top < popperRect.height && popperAltOverflow.top > 0; - const isIntersectingBottom = popperAltOverflow.bottom < popperRect.height && popperAltOverflow.bottom > 0; - - const isIntersecting = isIntersectingTop || isIntersectingBottom; - - state.modifiersData[name] = { - isIntersecting, - }; - state.attributes.popper = { - ...state.attributes.popper, - 'data-popper-is-intersecting': isIntersecting, - }; - }, -}; - -type IsIntersectingModifier = Modifier<'is-intersecting-modifier', never>; diff --git a/packages/react-components/react-positioning/src/middleware/coverTarget.ts b/packages/react-components/react-positioning/src/middleware/coverTarget.ts new file mode 100644 index 00000000000000..bbc9e119ce596f --- /dev/null +++ b/packages/react-components/react-positioning/src/middleware/coverTarget.ts @@ -0,0 +1,30 @@ +import type { Middleware } from '@floating-ui/dom'; +import { parseFloatingUIPlacement } from '../utils/index'; + +export function coverTarget(): Middleware { + return { + name: 'coverTarget', + fn: middlewareArguments => { + const { placement, rects, x, y } = middlewareArguments; + const basePlacement = parseFloatingUIPlacement(placement).side; + const newCoords = { x, y }; + + switch (basePlacement) { + case 'bottom': + newCoords.y -= rects.reference.height; + break; + case 'top': + newCoords.y += rects.reference.height; + break; + case 'left': + newCoords.x += rects.reference.width; + break; + case 'right': + newCoords.x -= rects.reference.width; + break; + } + + return newCoords; + }, + }; +} diff --git a/packages/react-components/react-positioning/src/middleware/flip.ts b/packages/react-components/react-positioning/src/middleware/flip.ts new file mode 100644 index 00000000000000..9f390efa2eccbb --- /dev/null +++ b/packages/react-components/react-positioning/src/middleware/flip.ts @@ -0,0 +1,18 @@ +import { flip as baseFlip } from '@floating-ui/dom'; +import type { PositioningOptions } from '../types'; +import { getBoundary } from '../utils/index'; + +export interface FlipMiddlewareOptions extends Pick { + hasScrollableElement?: boolean; + container: HTMLElement | null; +} + +export function flip(options: FlipMiddlewareOptions) { + const { hasScrollableElement, flipBoundary, container } = options; + + return baseFlip({ + ...(hasScrollableElement && { boundary: 'clippingAncestors' }), + ...(flipBoundary && { altBoundary: true, boundary: getBoundary(container, flipBoundary) }), + fallbackStrategy: 'bestFit', + }); +} diff --git a/packages/react-components/react-positioning/src/middleware/index.ts b/packages/react-components/react-positioning/src/middleware/index.ts new file mode 100644 index 00000000000000..edd88c2cf6dbec --- /dev/null +++ b/packages/react-components/react-positioning/src/middleware/index.ts @@ -0,0 +1,6 @@ +export * from './coverTarget'; +export * from './flip'; +export * from './intersecting'; +export * from './maxSize'; +export * from './offset'; +export * from './shift'; diff --git a/packages/react-components/react-positioning/src/middleware/intersecting.ts b/packages/react-components/react-positioning/src/middleware/intersecting.ts new file mode 100644 index 00000000000000..b09865f265e86f --- /dev/null +++ b/packages/react-components/react-positioning/src/middleware/intersecting.ts @@ -0,0 +1,23 @@ +import type { Middleware } from '@floating-ui/dom'; +import { detectOverflow } from '@floating-ui/dom'; + +export function intersecting(): Middleware { + return { + name: 'intersectionObserver', + fn: async middlewareArguments => { + const floatingRect = middlewareArguments.rects.floating; + const altOverflow = await detectOverflow(middlewareArguments, { altBoundary: true }); + + const isIntersectingTop = altOverflow.top < floatingRect.height && altOverflow.top > 0; + const isIntersectingBottom = altOverflow.bottom < floatingRect.height && altOverflow.bottom > 0; + + const isIntersecting = isIntersectingTop || isIntersectingBottom; + + return { + data: { + intersecting: isIntersecting, + }, + }; + }, + }; +} diff --git a/packages/react-components/react-positioning/src/middleware/maxSize.ts b/packages/react-components/react-positioning/src/middleware/maxSize.ts new file mode 100644 index 00000000000000..645b413db70ac0 --- /dev/null +++ b/packages/react-components/react-positioning/src/middleware/maxSize.ts @@ -0,0 +1,39 @@ +import { detectOverflow } from '@floating-ui/dom'; +import type { Middleware, Side } from '@floating-ui/dom'; +import type { PositioningOptions } from '../types'; +import { parseFloatingUIPlacement } from '../utils/index'; + +export function maxSize(autoSize: PositioningOptions['autoSize']): Middleware { + return { + name: 'maxSize', + fn: async middlewareArguments => { + const { placement, rects, elements, middlewareData } = middlewareArguments; + const basePlacement = parseFloatingUIPlacement(placement).side; + + const overflow = await detectOverflow(middlewareArguments); + const { x, y } = middlewareData.shift || { x: 0, y: 0 }; + const { width, height } = rects.floating; + + const widthProp: Side = basePlacement === 'left' ? 'left' : 'right'; + const heightProp: Side = basePlacement === 'top' ? 'top' : 'bottom'; + + const applyMaxWidth = + autoSize === 'always' || + autoSize === 'width-always' || + (overflow[widthProp] > 0 && (autoSize === true || autoSize === 'width')); + const applyMaxHeight = + autoSize === 'always' || + autoSize === 'height-always' || + (overflow[heightProp] > 0 && (autoSize === true || autoSize === 'height')); + + if (applyMaxWidth) { + elements.floating.style.maxWidth = `${width - overflow[widthProp] - x}px`; + } + if (applyMaxHeight) { + elements.floating.style.maxHeight = `${height - overflow[heightProp] - y}px`; + } + + return {}; + }, + }; +} diff --git a/packages/react-components/react-positioning/src/middleware/offset.ts b/packages/react-components/react-positioning/src/middleware/offset.ts new file mode 100644 index 00000000000000..1ea3420fe8f2a6 --- /dev/null +++ b/packages/react-components/react-positioning/src/middleware/offset.ts @@ -0,0 +1,11 @@ +import { offset as baseOffset } from '@floating-ui/dom'; +import type { PositioningOptions } from '../types'; +import { getFloatingUIOffset } from '../utils/getFloatingUIOffset'; + +/** + * Wraps floating UI offset middleware to to transform offset value + */ +export function offset(offsetValue: PositioningOptions['offset']) { + const floatingUIOffset = getFloatingUIOffset(offsetValue); + return baseOffset(floatingUIOffset); +} diff --git a/packages/react-components/react-positioning/src/middleware/shift.ts b/packages/react-components/react-positioning/src/middleware/shift.ts new file mode 100644 index 00000000000000..1f4598a9e90179 --- /dev/null +++ b/packages/react-components/react-positioning/src/middleware/shift.ts @@ -0,0 +1,25 @@ +import { shift as baseShift, limitShift } from '@floating-ui/dom'; +import type { PositioningOptions } from '../types'; +import { getBoundary } from '../utils/index'; + +export interface ShiftMiddlewareOptions extends Pick { + hasScrollableElement?: boolean; + disableTether?: PositioningOptions['unstable_disableTether']; + container: HTMLElement | null; +} + +/** + * Wraps the floating UI shift middleware for easier usage of our options + */ +export function shift(options: ShiftMiddlewareOptions) { + const { hasScrollableElement, disableTether, overflowBoundary, container } = options; + + return baseShift({ + ...(hasScrollableElement && { boundary: 'clippingAncestors' }), + ...(disableTether && { + crossAxis: disableTether === 'all', + limiter: limitShift({ crossAxis: disableTether !== 'all', mainAxis: false }), + }), + ...(overflowBoundary && { altBoundary: true, boundary: getBoundary(container, overflowBoundary) }), + }); +} diff --git a/packages/react-components/react-positioning/src/usePositioning.ts b/packages/react-components/react-positioning/src/usePositioning.ts index 6204f48e12cdce..c32e7f5802f5be 100644 --- a/packages/react-components/react-positioning/src/usePositioning.ts +++ b/packages/react-components/react-positioning/src/usePositioning.ts @@ -1,293 +1,38 @@ -import { useEventCallback, useIsomorphicLayoutEffect, useFirstMount, canUseDOM } from '@fluentui/react-utilities'; +import { computePosition, hide as hideMiddleware, arrow as arrowMiddleware } from '@floating-ui/dom'; +import type { Middleware, Strategy, Placement, Coords, MiddlewareData } from '@floating-ui/dom'; import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; -import * as PopperJs from '@popperjs/core'; +import { canUseDOM, useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; +import { useEventCallback } from '@fluentui/react-utilities'; import * as React from 'react'; - -import { isIntersectingModifier } from './isIntersectingModifier'; +import type { PositioningOptions, PositioningProps, PositioningVirtualElement } from './types'; import { - getScrollParent, - applyRtlToOffset, - getPlacement, - getReactFiberFromNode, - getBoundary, useCallbackRef, - parsePopperPlacement, -} from './utils/index'; -import type { PositioningVirtualElement, PositioningOptions, PositioningProps } from './types'; -import { getPopperOffset } from './utils/getPopperOffset'; - -type PopperInstance = PopperJs.Instance & { isFirstRun?: boolean }; - -interface UsePopperOptions extends PositioningProps { - /** - * If false, delays Popper's creation. - * @default true - */ - enabled?: boolean; -} - -// -// Dev utils to detect if nodes have "autoFocus" props. -// - -/** - * Detects if a passed HTML node has "autoFocus" prop on a React's fiber. Is needed as React handles autofocus behavior - * in React DOM and will not pass "autoFocus" to an actual HTML. - */ -function hasAutofocusProp(node: Node): boolean { - // https://github.com/facebook/react/blob/848bb2426e44606e0a55dfe44c7b3ece33772485/packages/react-dom/src/client/ReactDOMHostConfig.js#L157-L166 - const isAutoFocusableElement = - node.nodeName === 'BUTTON' || - node.nodeName === 'INPUT' || - node.nodeName === 'SELECT' || - node.nodeName === 'TEXTAREA'; - - if (isAutoFocusableElement) { - return !!getReactFiberFromNode(node)?.pendingProps.autoFocus; - } - - return false; -} - -function hasAutofocusFilter(node: Node) { - return hasAutofocusProp(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP; -} - -/** - * Provides a callback to resolve Popper options, it's stable and should be used as a dependency to trigger updates - * of Popper options. - * - * A callback is used there intentionally as some of Popper.js modifiers require DOM nodes (targer, container, arrow) - * that can't be resolved properly during an initial rendering. - */ -function usePopperOptions(options: PositioningOptions, popperOriginalPositionRef: React.MutableRefObject) { - const { - align, - arrowPadding, - autoSize, - coverTarget, - flipBoundary, - offset, - overflowBoundary, - pinned, - position, - positionFixed, - // eslint-disable-next-line @typescript-eslint/naming-convention - unstable_disableTether, - } = options; - - const isRtl = useFluent().dir === 'rtl'; - const placement = getPlacement(align, position, isRtl); - const strategy = positionFixed ? 'fixed' : 'absolute'; - - const offsetModifier = React.useMemo( - () => - offset - ? { - name: 'offset', - options: { offset: isRtl ? applyRtlToOffset(getPopperOffset(offset)) : getPopperOffset(offset) }, - } - : null, - [offset, isRtl], - ); - - return React.useCallback( - ( - target: HTMLElement | PopperJs.VirtualElement | null, - container: HTMLElement | null, - arrow: HTMLElement | null, - ): PopperJs.Options => { - const scrollParentElement: HTMLElement = getScrollParent(container); - const hasScrollableElement = scrollParentElement - ? scrollParentElement !== scrollParentElement.ownerDocument?.body - : false; - - const modifiers: PopperJs.Options['modifiers'] = [ - isIntersectingModifier, - - /** - * We are setting the position to `fixed` in the first effect to prevent scroll jumps in case of the content - * with managed focus. Modifier sets the position to `fixed` before all other modifier effects. Another part of - * a patch modifies ".forceUpdate()" directly after a Popper will be created. - */ - { - name: 'positionStyleFix', - enabled: true, - phase: 'afterWrite' as PopperJs.ModifierPhases, - effect: ({ state, instance }: { state: PopperJs.State; instance: PopperInstance }) => { - // ".isFirstRun" is a part of our patch, on a first evaluation it will "undefined" - // should be disabled for subsequent runs as it breaks positioning for them - if (instance.isFirstRun !== false) { - popperOriginalPositionRef.current = state.elements.popper.style.position; - state.elements.popper.style.position = 'fixed'; - } - - return () => undefined; - }, - requires: [], - }, - - { name: 'flip', options: { flipVariations: true } }, - - /** - * pinned disables the flip modifier by setting flip.enabled to false; this - * disables automatic repositioning of the popper box; it will always be placed according to - * the values of `align` and `position` props, regardless of the size of the component, the - * reference element or the viewport. - */ - pinned && { name: 'flip', enabled: false }, - - /** - * When the popper box is placed in the context of a scrollable element, we need to set - * preventOverflow.escapeWithReference to true and flip.boundariesElement to 'scrollParent' - * (default is 'viewport') so that the popper box will stick with the targetRef when we - * scroll targetRef out of the viewport. - */ - hasScrollableElement && { name: 'flip', options: { boundary: 'clippingParents' } }, - hasScrollableElement && { name: 'preventOverflow', options: { boundary: 'clippingParents' } }, - - offsetModifier, - - /** - * This modifier is necessary to retain behaviour from popper v1 where untethered poppers are allowed by - * default. i.e. popper is still rendered fully in the viewport even if anchor element is no longer in the - * viewport. - */ - unstable_disableTether && { - name: 'preventOverflow', - options: { altAxis: unstable_disableTether === 'all', tether: false }, - }, - - flipBoundary && { - name: 'flip', - options: { - altBoundary: true, - boundary: getBoundary(container, flipBoundary), - }, - }, - overflowBoundary && { - name: 'preventOverflow', - options: { - altBoundary: true, - boundary: getBoundary(container, overflowBoundary), - }, - }, - - { - // Similar code as popper-maxsize-modifier: https://github.com/atomiks/popper.js/blob/master/src/modifiers/maxSize.js - // popper-maxsize-modifier only calculates the max sizes. - // This modifier can apply max sizes always, or apply the max sizes only when overflow is detected - name: 'applyMaxSize', - enabled: !!autoSize, - phase: 'beforeWrite' as PopperJs.ModifierPhases, - requiresIfExists: ['offset', 'preventOverflow', 'flip'], - options: { - altBoundary: true, - boundary: getBoundary(container, overflowBoundary), - }, - fn({ state, options: modifierOptions }: PopperJs.ModifierArguments<{}>) { - const overflow = PopperJs.detectOverflow(state, modifierOptions); - const { x, y } = state.modifiersData.preventOverflow || { x: 0, y: 0 }; - const { width, height } = state.rects.popper; - const basePlacement = parsePopperPlacement(state.placement).basePlacement; - - const widthProp: keyof PopperJs.SideObject = basePlacement === 'left' ? 'left' : 'right'; - const heightProp: keyof PopperJs.SideObject = basePlacement === 'top' ? 'top' : 'bottom'; - - const applyMaxWidth = - autoSize === 'always' || - autoSize === 'width-always' || - (overflow[widthProp] > 0 && (autoSize === true || autoSize === 'width')); - const applyMaxHeight = - autoSize === 'always' || - autoSize === 'height-always' || - (overflow[heightProp] > 0 && (autoSize === true || autoSize === 'height')); - - if (applyMaxWidth) { - state.styles.popper.maxWidth = `${width - overflow[widthProp] - x}px`; - } - if (applyMaxHeight) { - state.styles.popper.maxHeight = `${height - overflow[heightProp] - y}px`; - } - }, - }, - - /** - * This modifier is necessary in order to render the pointer. Refs are resolved in effects, so it can't be - * placed under computed modifiers. Deep merge is not required as this modifier has only these properties. - */ - { - name: 'arrow', - enabled: !!arrow, - options: { element: arrow, padding: arrowPadding }, - }, - - /** - * Modifies popper offsets to cover the reference rect, but still keep edge alignment - */ - { - name: 'coverTarget', - enabled: !!coverTarget, - phase: 'main', - requiresIfExists: ['offset', 'preventOverflow', 'flip'], - fn({ state }: PopperJs.ModifierArguments<{}>) { - const basePlacement = parsePopperPlacement(state.placement).basePlacement; - switch (basePlacement) { - case 'bottom': - state.modifiersData.popperOffsets!.y -= state.rects.reference.height; - break; - case 'top': - state.modifiersData.popperOffsets!.y += state.rects.reference.height; - break; - case 'left': - state.modifiersData.popperOffsets!.x += state.rects.reference.width; - break; - case 'right': - state.modifiersData.popperOffsets!.x -= state.rects.reference.width; - break; - } - }, - }, - ].filter(Boolean) as PopperJs.Options['modifiers']; // filter boolean conditional spreading values - - const popperOptions: PopperJs.Options = { - modifiers, - - placement, - strategy, - }; - - return popperOptions; - }, - [ - arrowPadding, - autoSize, - coverTarget, - flipBoundary, - offsetModifier, - overflowBoundary, - placement, - strategy, - unstable_disableTether, - pinned, - - // These can be skipped from deps as they will not ever change - popperOriginalPositionRef, - ], - ); -} + toFloatingUIPlacement, + toggleScrollListener, + hasAutofocusFilter, + debounce, + hasScrollParent, +} from './utils'; +import { + shift as shiftMiddleware, + flip as flipMiddleware, + coverTarget as coverTargetMiddleware, + maxSize as maxSizeMiddleware, + offset as offsetMiddleware, + intersecting as intersectingMiddleware, +} from './middleware'; +import { + DATA_POSITIONING_ESCAPED, + DATA_POSITIONING_INTERSECTING, + DATA_POSITIONING_HIDDEN, + DATA_POSITIONING_PLACEMENT, +} from './constants'; /** * @internal - * Exposes Popper positioning API via React hook. Contains few important differences between an official "react-popper" - * package: - * - style attributes are applied directly on DOM to avoid re-renders - * - refs changes and resolution is handled properly without re-renders - * - contains a specific to React fix related to initial positioning when containers have components with managed focus - * to avoid focus jumps */ export function usePositioning( - options: UsePopperOptions = {}, + options: UsePositioningOptions, ): { // React refs are supposed to be contravariant // (allows a more general type to be passed rather than a more specific one) @@ -300,90 +45,63 @@ export function usePositioning( // eslint-disable-next-line @typescript-eslint/no-explicit-any arrowRef: React.MutableRefObject; } { + const { targetDocument } = useFluent(); const { enabled = true } = options; - const isFirstMount = useFirstMount(); - - const popperOriginalPositionRef = React.useRef('absolute'); - const resolvePopperOptions = usePopperOptions(options, popperOriginalPositionRef); - - const popperInstanceRef = React.useRef(null); - - const handlePopperUpdate = useEventCallback(() => { - popperInstanceRef.current?.destroy(); - popperInstanceRef.current = null; + const resolvePositioningOptions = usePositioningOptions(options); + const forceUpdate = useEventCallback(() => { const target = overrideTargetRef.current ?? targetRef.current; - - let popperInstance: PopperInstance | null = null; - - if (canUseDOM() && enabled) { - if (target && containerRef.current) { - popperInstance = PopperJs.createPopper( - target, - containerRef.current, - resolvePopperOptions(target, containerRef.current, arrowRef.current), - ); - } + if (!canUseDOM || !enabled || !target || !containerRef.current) { + return; } - if (popperInstance) { - /** - * The patch updates `.forceUpdate()` Popper function which restores the original position before the first - * forceUpdate() call. See also "positionStyleFix" modifier in usePopperOptions(). - */ - const originalForceUpdate = popperInstance.forceUpdate; - - popperInstance.isFirstRun = true; - popperInstance.forceUpdate = () => { - if (popperInstance?.isFirstRun) { - popperInstance.state.elements.popper.style.position = popperOriginalPositionRef.current; - popperInstance.isFirstRun = false; + const { placement, middleware, strategy } = resolvePositioningOptions( + target, + containerRef.current, + arrowRef.current, + ); + + // Container is always initialized with `position: fixed` to avoid scroll jumps + // Before computing the positioned coordinates, revert the container to the deisred positioning strategy + Object.assign(containerRef.current.style, { position: strategy }); + computePosition(target, containerRef.current, { placement, middleware, strategy }) + .then(({ x, y, middlewareData, placement: computedPlacement }) => { + writeArrowUpdates({ arrow: arrowRef.current, middlewareData }); + writeContainerUpdates({ + container: containerRef.current, + middlewareData, + placement: computedPlacement, + coordinates: { x, y }, + lowPPI: (targetDocument?.defaultView?.devicePixelRatio || 1) <= 1, + strategy, + }); + }) + .catch(err => { + // https://github.com/floating-ui/floating-ui/issues/1845 + // FIXME for node > 14 + // node 15 introduces promise rejection which means that any components + // tests need to be `it('', async () => {})` otherwise there can be race conditions with + // JSDOM being torn down before this promise is resolved so globals like `window` and `document` don't exist + // Unless all tests that ever use `usePositioning` are turned into async tests, any logging during testing + // will actually be counter productive + if (process.env.NODE_ENV === 'development') { + // eslint-disable-next-line no-console + console.error('[usePositioning]: Failed to calculate position', err); } - - originalForceUpdate(); - }; - } - - popperInstanceRef.current = popperInstance; + }); }); - // Refs are managed by useCallbackRef() to handle ref updates scenarios. - // - // The first scenario are updates for a targetRef, we can handle it properly only via callback refs, but it causes - // re-renders (we would like to avoid them). - // - // The second problem is related to refs resolution on React's layer: refs are resolved in the same order as effects - // that causes an issue when you have a container inside a target, for example: a menu in ChatMessage. - // - // function ChatMessage(props) { - //
// 3) ref will be resolved only now, but it's too late already - // // 2) create a popper instance - //
// 1) capture ref from this element - // - //
- // } - // - // Check a demo on CodeSandbox: https://codesandbox.io/s/popper-refs-emy60. - // - // This again can be solved with callback refs. It's not a huge issue as with hooks we are moving popper's creation - // to ChatMessage itself, however, without `useCallback()` refs it's still a Pandora box. - const targetRef = useCallbackRef(null, handlePopperUpdate, true); - const containerRef = useCallbackRef(null, handlePopperUpdate, true); - const arrowRef = useCallbackRef(null, handlePopperUpdate, true); - - // Stores external target from options.target or setTarget - const overrideTargetRef = useCallbackRef( - null, - handlePopperUpdate, - true, - ); + const updatePosition = React.useState(() => debounce(forceUpdate))[0]; + + const targetRef = useTargetRef(updatePosition); + const overrideTargetRef = useTargetRef(updatePosition); + const containerRef = useContainerRef(updatePosition, enabled); + const arrowRef = useArrowRef(updatePosition); React.useImperativeHandle( options.positioningRef, () => ({ - updatePosition: () => { - popperInstanceRef.current?.update(); - }, + updatePosition, setTarget: (target: HTMLElement | PositioningVirtualElement) => { if (options.target && process.env.NODE_ENV !== 'production') { const err = new Error(); @@ -398,7 +116,8 @@ export function usePositioning( }), // Missing deps: // options.target - only used for a runtime warning - // targetRef - Stable between renders + // overrideTargetRef - Stable between renders + // updatePosition - Stable between renders // eslint-disable-next-line react-hooks/exhaustive-deps [], ); @@ -407,29 +126,25 @@ export function usePositioning( if (options.target) { overrideTargetRef.current = options.target; } - }, [options.target, overrideTargetRef]); + }, [options.target, overrideTargetRef, containerRef]); + useIsomorphicLayoutEffect(() => { - handlePopperUpdate(); - - return () => { - popperInstanceRef.current?.destroy(); - popperInstanceRef.current = null; - }; - }, [handlePopperUpdate, options.enabled]); - useIsomorphicLayoutEffect( - () => { - if (!isFirstMount) { - popperInstanceRef.current?.setOptions( - resolvePopperOptions(overrideTargetRef.current ?? targetRef.current, containerRef.current, arrowRef.current), - ); - } - }, - // Missing deps: - // isFirstMount - Should never change after mount - // arrowRef, containerRef, targetRef - Stable between renders - // eslint-disable-next-line react-hooks/exhaustive-deps - [resolvePopperOptions], - ); + updatePosition(); + }, [enabled, resolvePositioningOptions, updatePosition]); + + // Add window resize and scroll listeners to update position + useIsomorphicLayoutEffect(() => { + const win = targetDocument?.defaultView; + if (win) { + win.addEventListener('resize', updatePosition); + win.addEventListener('scroll', updatePosition); + + return () => { + win.removeEventListener('resize', updatePosition); + win.removeEventListener('scroll', updatePosition); + }; + } + }, [updatePosition, targetDocument]); if (process.env.NODE_ENV !== 'production') { // This checked should run only in development mode @@ -441,7 +156,7 @@ export function usePositioning( acceptNode: hasAutofocusFilter, }); - while (treeWalker?.nextNode()) { + while (treeWalker.nextNode()) { const node = treeWalker.currentNode; // eslint-disable-next-line no-console console.warn(':', node); @@ -475,3 +190,165 @@ export function usePositioning( return { targetRef, containerRef, arrowRef }; } + +interface UsePositioningOptions extends PositioningProps { + /** + * If false, does not position anything + */ + enabled?: boolean; +} + +function usePositioningOptions(options: PositioningOptions) { + const { + align, + arrowPadding, + autoSize, + coverTarget, + flipBoundary, + offset, + overflowBoundary, + pinned, + position, + unstable_disableTether: disableTether, + positionFixed, + } = options; + + const { dir } = useFluent(); + const isRtl = dir === 'rtl'; + const strategy: Strategy = positionFixed ? 'fixed' : 'absolute'; + + return React.useCallback( + ( + target: HTMLElement | PositioningVirtualElement | null, + container: HTMLElement | null, + arrow: HTMLElement | null, + ) => { + const hasScrollableElement = hasScrollParent(container); + + const placement = toFloatingUIPlacement(align, position, isRtl); + const middleware = [ + offset && offsetMiddleware(offset), + coverTarget && coverTargetMiddleware(), + !pinned && flipMiddleware({ container, flipBoundary, hasScrollableElement }), + shiftMiddleware({ container, hasScrollableElement, overflowBoundary, disableTether }), + autoSize && maxSizeMiddleware(autoSize), + intersectingMiddleware(), + arrow && arrowMiddleware({ element: arrow, padding: arrowPadding }), + hideMiddleware({ strategy: 'referenceHidden' }), + hideMiddleware({ strategy: 'escaped' }), + ].filter(Boolean) as Middleware[]; + + return { + placement, + middleware, + strategy, + }; + }, + [ + align, + arrowPadding, + autoSize, + coverTarget, + disableTether, + flipBoundary, + isRtl, + offset, + overflowBoundary, + pinned, + position, + strategy, + ], + ); +} + +function useContainerRef(updatePosition: () => void, enabled: boolean) { + return useCallbackRef(null, (container, prevContainer) => { + if (container && enabled) { + // When the container is first resolved, set position `fixed` to avoid scroll jumps. + // Without this scroll jumps can occur when the element is rendered initially and receives focus + Object.assign(container.style, { position: 'fixed', left: 0, top: 0, margin: 0 }); + } + + toggleScrollListener(container, prevContainer, updatePosition); + + updatePosition(); + }); +} + +function useTargetRef(updatePosition: () => void) { + return useCallbackRef(null, (target, prevTarget) => { + toggleScrollListener(target, prevTarget, updatePosition); + + updatePosition(); + }); +} + +function useArrowRef(updatePosition: () => void) { + return useCallbackRef(null, updatePosition); +} + +/** + * Writes all DOM element updates after position is computed + */ +function writeContainerUpdates(options: { + container: HTMLElement | null; + placement: Placement; + middlewareData: MiddlewareData; + /** + * Layer acceleration can disable subpixel rendering which causes slightly + * blurry text on low PPI displays, so we want to use 2D transforms + * instead + */ + lowPPI: boolean; + strategy: Strategy; + coordinates: Coords; +}) { + const { + container, + placement, + middlewareData, + strategy, + lowPPI, + coordinates: { x, y }, + } = options; + if (!container) { + return; + } + container.setAttribute(DATA_POSITIONING_PLACEMENT, placement); + container.removeAttribute(DATA_POSITIONING_INTERSECTING); + if (middlewareData.intersectionObserver.intersecting) { + container.setAttribute(DATA_POSITIONING_INTERSECTING, ''); + } + + container.removeAttribute(DATA_POSITIONING_ESCAPED); + if (middlewareData.hide?.escaped) { + container.setAttribute(DATA_POSITIONING_ESCAPED, ''); + } + + container.removeAttribute(DATA_POSITIONING_HIDDEN); + if (middlewareData.hide?.referenceHidden) { + container.setAttribute(DATA_POSITIONING_HIDDEN, ''); + } + + Object.assign(container.style, { + transform: lowPPI ? `translate(${x}px, ${y}px)` : `translate3d(${x}px, ${y}px, 0)`, + position: strategy, + }); +} + +/** + * Writes all DOM element updates after position is computed + */ +function writeArrowUpdates(options: { arrow: HTMLElement | null; middlewareData: MiddlewareData }) { + const { arrow, middlewareData } = options; + if (!middlewareData.arrow || !arrow) { + return; + } + + const { x: arrowX, y: arrowY } = middlewareData.arrow; + + Object.assign(arrow.style, { + left: `${arrowX}px`, + top: `${arrowY}px`, + }); +} diff --git a/packages/react-components/react-positioning/src/utils/debounce.ts b/packages/react-components/react-positioning/src/utils/debounce.ts new file mode 100644 index 00000000000000..e273588b933b14 --- /dev/null +++ b/packages/react-components/react-positioning/src/utils/debounce.ts @@ -0,0 +1,21 @@ +/** + * Promise microtask debouncer used by Popper.js v2 + * This is no longer exported in Floating UI (Popper.js v3) + * https://github.com/floating-ui/floating-ui/blob/v2.x/src/utils/debounce.js + * @param fn function that will be debounced + */ +export function debounce(fn: Function): () => Promise { + let pending: Promise | undefined; + return () => { + if (!pending) { + pending = new Promise(resolve => { + Promise.resolve().then(() => { + pending = undefined; + resolve(fn()); + }); + }); + } + + return pending; + }; +} diff --git a/packages/react-components/react-positioning/src/utils/fromFloatingUIPlacement.test.ts b/packages/react-components/react-positioning/src/utils/fromFloatingUIPlacement.test.ts new file mode 100644 index 00000000000000..d5dfbb37ec7b0d --- /dev/null +++ b/packages/react-components/react-positioning/src/utils/fromFloatingUIPlacement.test.ts @@ -0,0 +1,64 @@ +import { fromFloatingUIPlacement } from './fromFloatingUIPlacement'; +import type { Placement } from '@floating-ui/dom'; + +describe('fromFloatingUIPlacement', () => { + it.each([ + //[align, position, placement] + ['top-start', 'start', 'above'], + ['top', undefined, 'above'], + ['top-end', 'end', 'above'], + ['bottom-start', 'start', 'below'], + ['bottom', undefined, 'below'], + ['bottom-end', 'end', 'below'], + ['left-start', 'top', 'before'], + ['left', undefined, 'before'], + ['left-end', 'bottom', 'before'], + ['right-start', 'top', 'after'], + ['right', undefined, 'after'], + ['right-end', 'bottom', 'after'], + ['top', undefined, 'above'], + ['bottom', undefined, 'below'], + ['left', undefined, 'before'], + ['right', undefined, 'after'], + ])( + 'should use placement %s and return position: %s and alignment: %s', + (placement, expectedAlignment, expectedPosition) => { + // Act + const { position, alignment } = fromFloatingUIPlacement(placement as Placement); + + // Assert + expect(position).toEqual(expectedPosition); + expect(alignment).toEqual(expectedAlignment); + }, + ); + + it.each([ + //[align, position, placement, rtlPlacement] + ['top-start', 'start', 'above'], + ['top', undefined, 'above'], + ['top-end', 'end', 'above'], + ['bottom-start', 'start', 'below'], + ['bottom', undefined, 'below'], + ['bottom-end', 'end', 'below'], + ['left-start', 'top', 'before'], + ['left', undefined, 'before'], + ['left-end', 'bottom', 'before'], + ['right-start', 'top', 'after'], + ['right', undefined, 'after'], + ['right-end', 'bottom', 'after'], + ['top', undefined, 'above'], + ['bottom', undefined, 'below'], + ['left', undefined, 'before'], + ['right', undefined, 'after'], + ])( + 'should use placement %s and return position: %s and alignment: %s', + (placement, expectedAlignment, expectedPosition) => { + // Act + const { position, alignment } = fromFloatingUIPlacement(placement as Placement); + + // Assert + expect(position).toEqual(expectedPosition); + expect(alignment).toEqual(expectedAlignment); + }, + ); +}); diff --git a/packages/react-components/react-positioning/src/utils/fromFloatingUIPlacement.ts b/packages/react-components/react-positioning/src/utils/fromFloatingUIPlacement.ts new file mode 100644 index 00000000000000..15255c5ece1a9d --- /dev/null +++ b/packages/react-components/react-positioning/src/utils/fromFloatingUIPlacement.ts @@ -0,0 +1,38 @@ +import type { Side, Alignment as FloatingUIAlignment, Placement } from '@floating-ui/dom'; +import type { Alignment, Position } from '../types'; +import { parseFloatingUIPlacement } from './parseFloatingUIPlacement'; + +const getPositionMap = (): Record => ({ + top: 'above', + bottom: 'below', + right: 'after', + left: 'before', +}); + +// Floating UI automatically flips alignment +// https://github.com/floating-ui/floating-ui/issues/1563 +const getAlignmentMap = (position: Position): Record => { + if (position === 'above' || position === 'below') { + return { + start: 'start', + end: 'end', + }; + } + + return { + start: 'top', + end: 'bottom', + }; +}; + +/** + * Maps Floating UI placement to positioning values + * @see positioningHelper.test.ts for expected placement values + */ +export const fromFloatingUIPlacement = (placement: Placement): { position: Position; alignment?: Alignment } => { + const { side, alignment: floatingUIAlignment } = parseFloatingUIPlacement(placement); + const position = getPositionMap()[side]; + const alignment = floatingUIAlignment && getAlignmentMap(position)[floatingUIAlignment]; + + return { position, alignment }; +}; diff --git a/packages/react-components/react-positioning/src/utils/fromPopperPlacement.test.ts b/packages/react-components/react-positioning/src/utils/fromPopperPlacement.test.ts deleted file mode 100644 index 81485ab7d6b42e..00000000000000 --- a/packages/react-components/react-positioning/src/utils/fromPopperPlacement.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { fromPopperPlacement } from './fromPopperPlacement'; -import type { Placement } from '@popperjs/core'; - -describe('fromPopperPlacement', () => { - it.each([ - //[align, position, placement] - ['top-start', 'start', 'above'], - ['top', undefined, 'above'], - ['top-end', 'end', 'above'], - ['bottom-start', 'start', 'below'], - ['bottom', undefined, 'below'], - ['bottom-end', 'end', 'below'], - ['left-start', 'top', 'before'], - ['left', undefined, 'before'], - ['left-end', 'bottom', 'before'], - ['right-start', 'top', 'after'], - ['right', undefined, 'after'], - ['right-end', 'bottom', 'after'], - ['top', undefined, 'above'], - ['bottom', undefined, 'below'], - ['left', undefined, 'before'], - ['right', undefined, 'after'], - ])( - 'should use placement %s and return position: %s and alignment: %s', - (placement, expectedAlignment, expectedPosition) => { - // Act - const { position, alignment } = fromPopperPlacement(placement as Placement); - - // Assert - expect(position).toEqual(expectedPosition); - expect(alignment).toEqual(expectedAlignment); - }, - ); -}); diff --git a/packages/react-components/react-positioning/src/utils/fromPopperPlacement.ts b/packages/react-components/react-positioning/src/utils/fromPopperPlacement.ts deleted file mode 100644 index 0f6029e462ce7e..00000000000000 --- a/packages/react-components/react-positioning/src/utils/fromPopperPlacement.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { Variation as PopperAlignment, Placement } from '@popperjs/core'; -import type { Alignment, Position } from '../types'; -import { parsePopperPlacement } from './parsePopperPlacement'; - -const positionMap = { - top: 'above', - bottom: 'below', - right: 'after', - left: 'before', -} as const; - -const getAlignmentMap = (position: Position): Record => { - if (position === 'above' || position === 'below') { - return { - start: 'start', - end: 'end', - }; - } - - return { - start: 'top', - end: 'bottom', - }; -}; - -/** - * Maps Popper.js placement to positioning values - * @see positioningHelper.test.ts for expected placement values - */ -export const fromPopperPlacement = (placement: Placement): { position: Position; alignment?: Alignment } => { - const { basePlacement, alignment: popperAlignment } = parsePopperPlacement(placement); - const position = positionMap[basePlacement]; - const alignment = popperAlignment && getAlignmentMap(position)[popperAlignment]; - - return { position, alignment }; -}; diff --git a/packages/react-components/react-positioning/src/utils/getBoundary.ts b/packages/react-components/react-positioning/src/utils/getBoundary.ts index 1c030af2da9ab0..482b29fb41013d 100644 --- a/packages/react-components/react-positioning/src/utils/getBoundary.ts +++ b/packages/react-components/react-positioning/src/utils/getBoundary.ts @@ -1,4 +1,4 @@ -import * as PopperJs from '@popperjs/core'; +import type { Boundary as FloatingUIBoundary } from '@floating-ui/dom'; import { getScrollParent } from './getScrollParent'; import type { Boundary } from '../types'; @@ -6,11 +6,15 @@ import type { Boundary } from '../types'; /** * Allows to mimic a behavior from V1 of Popper and accept `window` and `scrollParent` as strings. */ -export function getBoundary(element: HTMLElement | null, boundary?: Boundary | null): PopperJs.Boundary | undefined { +export function getBoundary(element: HTMLElement | null, boundary?: Boundary): FloatingUIBoundary | undefined { if (boundary === 'window') { return element?.ownerDocument!.documentElement; } + if (boundary === 'clippingParents') { + return 'clippingAncestors'; + } + if (boundary === 'scrollParent') { let boundariesNode: HTMLElement | undefined = getScrollParent(element); @@ -21,5 +25,5 @@ export function getBoundary(element: HTMLElement | null, boundary?: Boundary | n return boundariesNode; } - return boundary ?? undefined; + return boundary; } diff --git a/packages/react-components/react-positioning/src/utils/getFloatingUIOffset.test.ts b/packages/react-components/react-positioning/src/utils/getFloatingUIOffset.test.ts new file mode 100644 index 00000000000000..365f3b59b73c13 --- /dev/null +++ b/packages/react-components/react-positioning/src/utils/getFloatingUIOffset.test.ts @@ -0,0 +1,125 @@ +import { MiddlewareArguments } from '@floating-ui/dom'; +import { OffsetFunction } from '../types'; +import { FloatingUIOffsetFunction, getFloatingUIOffset } from './getFloatingUIOffset'; + +describe('getFloatingUIOffset', () => { + const testMiddlewareArgs: MiddlewareArguments = { + elements: { + reference: document.createElement('div'), + floating: document.createElement('div'), + }, + initialPlacement: 'top', + placement: 'top', + strategy: 'fixed', + rects: { + floating: { + x: 0, + y: 0, + height: 0, + width: 0, + }, + reference: { + x: 0, + y: 0, + height: 0, + width: 0, + }, + }, + platform: { + getElementRects: jest.fn(), + getClippingRect: jest.fn(), + getDimensions: jest.fn(), + convertOffsetParentRelativeRectToViewportRelativeRect: jest.fn(), + getOffsetParent: jest.fn(), + isElement: jest.fn(), + getDocumentElement: jest.fn(), + getClientRects: jest.fn(), + isRTL: jest.fn(), + }, + middlewareData: {}, + x: 0, + y: 0, + }; + + it('should ignore object offsets', () => { + const offset = { crossAxis: 10, mainAxis: 10 }; + const transformedOffset = getFloatingUIOffset({ crossAxis: 10, mainAxis: 10 }); + expect(transformedOffset).toEqual(offset); + }); + + it('should ignore number offsets', () => { + const offset = 10; + const transformedOffset = getFloatingUIOffset(offset); + expect(transformedOffset).toEqual(offset); + }); + + it('should keep function offset as function', () => { + const dummyRect = { x: 0, y: 0, width: 0, height: 0 }; + const offset = { crossAxis: 10, mainAxis: 10 }; + const transformedOffset = getFloatingUIOffset(() => offset) as FloatingUIOffsetFunction; + expect( + transformedOffset({ + ...testMiddlewareArgs, + rects: { floating: dummyRect, reference: dummyRect }, + placement: 'top', + }), + ).toEqual(offset); + }); + + it('should transform placement argument in function offset', () => { + const dummyRect = { x: 0, y: 0, width: 0, height: 0 }; + const offsetFn: OffsetFunction = ({ position, alignment }) => { + if (position === 'above' && alignment === 'start') { + return 1; + } + + return -1; + }; + const transformedOffset = getFloatingUIOffset(offsetFn) as FloatingUIOffsetFunction; + expect( + transformedOffset({ + ...testMiddlewareArgs, + rects: { floating: dummyRect, reference: dummyRect }, + placement: 'top-start', + }), + ).toEqual(1); + }); + + it('should rename floating property to positioned', () => { + const dummyRect = { x: 0, y: 0, width: 0, height: 0 }; + const offsetFn: OffsetFunction = ({ positionedRect }) => { + if (positionedRect === dummyRect) { + return 1; + } + + return -1; + }; + const transformedOffset = getFloatingUIOffset(offsetFn) as FloatingUIOffsetFunction; + expect( + transformedOffset({ + ...testMiddlewareArgs, + rects: { floating: dummyRect, reference: dummyRect }, + placement: 'top-start', + }), + ).toEqual(1); + }); + + it('should rename reference property to target', () => { + const dummyRect = { x: 0, y: 0, width: 0, height: 0 }; + const offsetFn: OffsetFunction = ({ targetRect }) => { + if (targetRect === dummyRect) { + return 1; + } + + return -1; + }; + const transformedOffset = getFloatingUIOffset(offsetFn) as FloatingUIOffsetFunction; + expect( + transformedOffset({ + ...testMiddlewareArgs, + rects: { floating: dummyRect, reference: dummyRect }, + placement: 'top-start', + }), + ).toEqual(1); + }); +}); diff --git a/packages/react-components/react-positioning/src/utils/getFloatingUIOffset.ts b/packages/react-components/react-positioning/src/utils/getFloatingUIOffset.ts new file mode 100644 index 00000000000000..d18e827ab0d9c9 --- /dev/null +++ b/packages/react-components/react-positioning/src/utils/getFloatingUIOffset.ts @@ -0,0 +1,55 @@ +import type { Offset } from '../types'; +import type { MiddlewareArguments } from '@floating-ui/dom'; +import { fromFloatingUIPlacement } from './fromFloatingUIPlacement'; +/** + * Type taken from Floating UI since they are not exported + */ +export type FloatingUIOffsetValue = + | number + | { + /** + * The axis that runs along the side of the floating element. + * @default 0 + */ + mainAxis?: number; + /** + * The axis that runs along the alignment of the floating element. + * @default 0 + */ + crossAxis?: number; + /** + * When set to a number, overrides the `crossAxis` value for aligned + * (non-centered/base) placements and works logically. A positive number + * will move the floating element in the direction of the opposite edge + * to the one that is aligned, while a negative number the reverse. + * @default null + */ + alignmentAxis?: number | null; + }; + +/** + * Type taken from Floating UI since they are not exported + */ +export type FloatingUIOffsetFunction = (args: MiddlewareArguments) => FloatingUIOffsetValue; + +/** + * Shim to transform offset values from this library to Floating UI + * @param rawOffset Offset from this library + * @returns An offset value compatible with Floating UI + */ +export function getFloatingUIOffset( + rawOffset: Offset | undefined, +): FloatingUIOffsetValue | FloatingUIOffsetFunction | undefined { + if (!rawOffset) { + return rawOffset; + } + + if (typeof rawOffset === 'number' || typeof rawOffset === 'object') { + return rawOffset; + } + + return ({ rects: { floating, reference }, placement }) => { + const { position, alignment } = fromFloatingUIPlacement(placement); + return rawOffset({ positionedRect: floating, targetRect: reference, position, alignment }); + }; +} diff --git a/packages/react-components/react-positioning/src/utils/getPopperOffset.test.ts b/packages/react-components/react-positioning/src/utils/getPopperOffset.test.ts deleted file mode 100644 index f87a0b3fcb8ca3..00000000000000 --- a/packages/react-components/react-positioning/src/utils/getPopperOffset.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { OffsetFunction } from '../types'; -import type { Rect } from '@popperjs/core'; -import { PopperOffsetFunction, getPopperOffset } from './getPopperOffset'; - -describe('getPopperOffset', () => { - it('should ignore object offsets', () => { - const transformedOffset = getPopperOffset({ crossAxis: 10, mainAxis: 10 }); - expect(transformedOffset).toEqual([10, 10]); - }); - - it('should ignore number offsets', () => { - const transformedOffset = getPopperOffset(10); - expect(transformedOffset).toEqual([0, 10]); - }); - - it('should keep function offset as function', () => { - const dummyRect: Rect = { x: 0, y: 0, width: 0, height: 0 }; - const offset = { crossAxis: 10, mainAxis: 10 }; - const transformedOffset = getPopperOffset(() => offset) as PopperOffsetFunction; - expect(transformedOffset({ popper: dummyRect, reference: dummyRect, placement: 'top' })).toEqual([ - offset.crossAxis, - offset.mainAxis, - ]); - }); - - it('should transform placement argument in function offset', () => { - const dummyRect: Rect = { x: 0, y: 0, width: 0, height: 0 }; - const offsetFn: OffsetFunction = ({ position, alignment }) => { - if (position === 'above' && alignment === 'start') { - return 1; - } - - return -1; - }; - const transformedOffset = getPopperOffset(offsetFn) as PopperOffsetFunction; - expect(transformedOffset({ popper: dummyRect, reference: dummyRect, placement: 'top-start' })).toEqual([0, 1]); - expect(transformedOffset({ popper: dummyRect, reference: dummyRect, placement: 'bottom-end' })).toEqual([0, -1]); - }); - - it('should rename popper property to positionedRect', () => { - const dummyRect: Rect = { x: 0, y: 0, width: 0, height: 0 }; - const offsetFn: OffsetFunction = ({ positionedRect }) => { - if (positionedRect === dummyRect) { - return 1; - } - - return -1; - }; - const transformedOffset = getPopperOffset(offsetFn) as PopperOffsetFunction; - expect(transformedOffset({ popper: dummyRect, reference: dummyRect, placement: 'top-start' })).toEqual([0, 1]); - }); - - it('should rename reference property to targetRect', () => { - const dummyRect: Rect = { x: 0, y: 0, width: 0, height: 0 }; - const offsetFn: OffsetFunction = ({ targetRect }) => { - if (targetRect === dummyRect) { - return 1; - } - - return -1; - }; - const transformedOffset = getPopperOffset(offsetFn) as PopperOffsetFunction; - expect(transformedOffset({ popper: dummyRect, reference: dummyRect, placement: 'top-start' })).toEqual([0, 1]); - }); -}); diff --git a/packages/react-components/react-positioning/src/utils/getPopperOffset.ts b/packages/react-components/react-positioning/src/utils/getPopperOffset.ts deleted file mode 100644 index ff21a27561b24c..00000000000000 --- a/packages/react-components/react-positioning/src/utils/getPopperOffset.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { Offset } from '../types'; -import type { Rect, Placement } from '@popperjs/core'; -import { fromPopperPlacement } from './fromPopperPlacement'; -/** - * Type taken from Popper since it is not exported - */ -export type PopperOffsetValue = [number | null | undefined, number | null | undefined]; - -/** - * Type taken from Popper since it is not exported - */ -export type PopperOffset = PopperOffsetValue | PopperOffsetFunction; - -/** - * Type taken from Popper.js since it is not exported - */ -export type PopperOffsetFunctionParam = { - popper: Rect; - reference: Rect; - placement: Placement; -}; - -/** - * Type taken from Popper.js since it is not exported - */ -export type PopperOffsetFunction = (args: { popper: Rect; reference: Rect; placement: Placement }) => PopperOffsetValue; - -/** - * Shim to transform offset values from this library to Popper.js - * @param rawOffset Offset from this library - * @returns An offset value compatible with Popper.js - */ -export function getPopperOffset(rawOffset: Offset | undefined): PopperOffsetValue | PopperOffsetFunction | undefined { - if (rawOffset === undefined || rawOffset === null) { - return rawOffset; - } - - if (typeof rawOffset === 'number') { - return [0, rawOffset]; - } - - if (typeof rawOffset === 'object') { - return [rawOffset.crossAxis, rawOffset.mainAxis]; - } - - return ({ popper, reference, placement }) => { - const { position, alignment } = fromPopperPlacement(placement); - const computedOffset = rawOffset({ positionedRect: popper, targetRect: reference, position, alignment }); - if (typeof computedOffset === 'number') { - return [0, computedOffset]; - } - - return [computedOffset.crossAxis, computedOffset.mainAxis]; - }; -} diff --git a/packages/react-components/react-positioning/src/utils/getScrollParent.ts b/packages/react-components/react-positioning/src/utils/getScrollParent.ts index c2392906709c69..eb9af2a63cbfd5 100644 --- a/packages/react-components/react-positioning/src/utils/getScrollParent.ts +++ b/packages/react-components/react-positioning/src/utils/getScrollParent.ts @@ -52,3 +52,8 @@ export const getScrollParent = (node: Document | HTMLElement | null): HTMLElemen return getScrollParent(parentNode); }; + +export const hasScrollParent = (node: Document | HTMLElement | null): boolean => { + const scrollParentElement: HTMLElement = getScrollParent(node); + return scrollParentElement ? scrollParentElement !== scrollParentElement.ownerDocument?.body : false; +}; diff --git a/packages/react-components/react-positioning/src/utils/hasAutoFocusFilter.ts b/packages/react-components/react-positioning/src/utils/hasAutoFocusFilter.ts new file mode 100644 index 00000000000000..55f4f23e1f8582 --- /dev/null +++ b/packages/react-components/react-positioning/src/utils/hasAutoFocusFilter.ts @@ -0,0 +1,30 @@ +// +// Dev utils to detect if nodes have "autoFocus" props. +// + +import { getReactFiberFromNode } from './getReactFiberFromNode'; + +/** + * Detects if a passed HTML node has "autoFocus" prop on a React's fiber. Is needed as React handles autofocus behavior + * in React DOM and will not pass "autoFocus" to an actual HTML. + * + * @param node + */ +function hasAutofocusProp(node: Node): boolean { + // https://github.com/facebook/react/blob/848bb2426e44606e0a55dfe44c7b3ece33772485/packages/react-dom/src/client/ReactDOMHostConfig.js#L157-L166 + const isAutoFocusableElement = + node.nodeName === 'BUTTON' || + node.nodeName === 'INPUT' || + node.nodeName === 'SELECT' || + node.nodeName === 'TEXTAREA'; + + if (isAutoFocusableElement) { + return !!getReactFiberFromNode(node)?.pendingProps.autoFocus; + } + + return false; +} + +export function hasAutofocusFilter(node: Node) { + return hasAutofocusProp(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP; +} diff --git a/packages/react-components/react-positioning/src/utils/index.ts b/packages/react-components/react-positioning/src/utils/index.ts index 17768c481a8758..f8d4270df5536b 100644 --- a/packages/react-components/react-positioning/src/utils/index.ts +++ b/packages/react-components/react-positioning/src/utils/index.ts @@ -1,8 +1,12 @@ +export * from './parseFloatingUIPlacement'; export * from './getBoundary'; export * from './getReactFiberFromNode'; export * from './getScrollParent'; export * from './mergeArrowOffset'; -export * from './positioningHelper'; +export * from './toFloatingUIPlacement'; +export * from './fromFloatingUIPlacement'; export * from './resolvePositioningShorthand'; export * from './useCallbackRef'; -export * from './parsePopperPlacement'; +export * from './debounce'; +export * from './toggleScrollListener'; +export * from './hasAutoFocusFilter'; diff --git a/packages/react-components/react-positioning/src/utils/mergeArrowOffset.ts b/packages/react-components/react-positioning/src/utils/mergeArrowOffset.ts index d19998b6240789..83f4d72701cdc3 100644 --- a/packages/react-components/react-positioning/src/utils/mergeArrowOffset.ts +++ b/packages/react-components/react-positioning/src/utils/mergeArrowOffset.ts @@ -1,10 +1,10 @@ import type { Offset, OffsetObject } from '../types'; /** - * @internal * Generally when adding an arrow to popper, it's necessary to offset the position of the popper by the * height of the arrow. A simple utility to merge a provided offset with an arrow height to return the final offset * + * @internal * @param userOffset - The offset provided by the user * @param arrowHeight - The height of the arrow in px * @returns User offset augmented with arrow height diff --git a/packages/react-components/react-positioning/src/utils/parseFloatingUIPlacement.test.ts b/packages/react-components/react-positioning/src/utils/parseFloatingUIPlacement.test.ts new file mode 100644 index 00000000000000..6ef033c4af2136 --- /dev/null +++ b/packages/react-components/react-positioning/src/utils/parseFloatingUIPlacement.test.ts @@ -0,0 +1,21 @@ +import * as FloatingUI from '@floating-ui/dom'; +import { parseFloatingUIPlacement } from './parseFloatingUIPlacement'; + +describe('getSide', () => { + it.each([ + ['top', { side: 'top' }], + ['bottom', { side: 'bottom' }], + ['right', { side: 'right' }], + ['left', { side: 'left' }], + ['top-start', { side: 'top', alignment: 'start' }], + ['top-end', { side: 'top', alignment: 'end' }], + ['bottom-start', { side: 'bottom', alignment: 'start' }], + ['bottom-end', { side: 'bottom', alignment: 'end' }], + ['right-start', { side: 'right', alignment: 'start' }], + ['right-end', { side: 'right', alignment: 'end' }], + ['left-start', { side: 'left', alignment: 'start' }], + ['left-end', { side: 'left', alignment: 'end' }], + ])('should return %s from %s', (placement, basePlacement) => { + expect(parseFloatingUIPlacement((placement as unknown) as FloatingUI.Placement)).toEqual(basePlacement); + }); +}); diff --git a/packages/react-components/react-positioning/src/utils/parseFloatingUIPlacement.ts b/packages/react-components/react-positioning/src/utils/parseFloatingUIPlacement.ts new file mode 100644 index 00000000000000..71f93a73df839b --- /dev/null +++ b/packages/react-components/react-positioning/src/utils/parseFloatingUIPlacement.ts @@ -0,0 +1,15 @@ +import type { Side, Placement, Alignment } from '@floating-ui/dom'; + +/** + * Parses Floating UI placement and returns the different components + * @param placement - the floating ui placement (i.e. bottom-start) + * + * @returns side and alignment components of the placement + */ +export function parseFloatingUIPlacement(placement: Placement): { side: Side; alignment: Alignment } { + const tokens = placement.split('-'); + return { + side: tokens[0] as Side, + alignment: tokens[1] as Alignment, + }; +} diff --git a/packages/react-components/react-positioning/src/utils/parsePopperPlacement.ts b/packages/react-components/react-positioning/src/utils/parsePopperPlacement.ts deleted file mode 100644 index a61f71375f0a2c..00000000000000 --- a/packages/react-components/react-positioning/src/utils/parsePopperPlacement.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { BasePlacement, Placement, Variation } from '@popperjs/core'; - -/** - * Parses Popper placement and returns the different components - * @param placement - the Popper.js placement (i.e. bottom-start) - * - * @returns side and alignment components of the placement - */ -export function parsePopperPlacement(placement: Placement): { basePlacement: BasePlacement; alignment: Variation } { - const tokens = placement.split('-'); - return { - basePlacement: tokens[0] as BasePlacement, - alignment: tokens[1] as Variation, - }; -} diff --git a/packages/react-components/react-positioning/src/utils/positioningHelper.test.ts b/packages/react-components/react-positioning/src/utils/positioningHelper.test.ts deleted file mode 100644 index c0e6ee74eb99a5..00000000000000 --- a/packages/react-components/react-positioning/src/utils/positioningHelper.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { getPlacement, applyRtlToOffset } from './positioningHelper'; -import type { Alignment, Position } from '../types'; -import { PopperOffsetFunction, PopperOffsetFunctionParam } from './getPopperOffset'; - -describe('getPlacement', () => { - it.each([ - //[align, position, placement, rtlPlacement] - ['start', 'above', 'top-start', 'top-end'], - ['center', 'above', 'top', 'top'], - ['end', 'above', 'top-end', 'top-start'], - ['start', 'below', 'bottom-start', 'bottom-end'], - ['center', 'below', 'bottom', 'bottom'], - ['end', 'below', 'bottom-end', 'bottom-start'], - ['top', 'before', 'left-start', 'right-start'], - ['center', 'before', 'left', 'right'], - ['bottom', 'before', 'left-end', 'right-end'], - ['top', 'after', 'right-start', 'left-start'], - ['center', 'after', 'right', 'left'], - ['bottom', 'after', 'right-end', 'left-end'], - [undefined, 'above', 'top', 'top'], - [undefined, 'below', 'bottom', 'bottom'], - [undefined, 'before', 'left', 'right'], - [undefined, 'after', 'right', 'left'], - [undefined, undefined, 'auto', 'auto'], - ])( - 'should use align: "%s" position: "%s" and return LTR placement: "%s" and RTL placement: "%s"', - (align, position, expectedPlacement, expectedRtlPlacement) => { - // Act - const placement = getPlacement(align as Alignment, position as Position); - const rtlPlacement = getPlacement(align as Alignment, position as Position, true); - - // Assert - expect(placement).toEqual(expectedPlacement); - expect(rtlPlacement).toEqual(expectedRtlPlacement); - }, - ); -}); - -describe('applyRtlOffset', () => { - it('flips an axis value RTL for an array', () => { - expect(applyRtlToOffset([10, 10])).toEqual([-10, 10]); - }); - - it('flips an axis value RTL for a function', () => { - // Arrange - const offsetFn: PopperOffsetFunction = () => [10, 10]; - const flippedFn = applyRtlToOffset(offsetFn) as PopperOffsetFunction; - - // Assert - expect(flippedFn(({} as unknown) as PopperOffsetFunctionParam)).toEqual([-10, 10]); - }); -}); diff --git a/packages/react-components/react-positioning/src/utils/positioningHelper.ts b/packages/react-components/react-positioning/src/utils/positioningHelper.ts deleted file mode 100644 index cc7fd160e58f26..00000000000000 --- a/packages/react-components/react-positioning/src/utils/positioningHelper.ts +++ /dev/null @@ -1,58 +0,0 @@ -import * as PopperJs from '@popperjs/core'; -import type { Alignment, Position } from '../types'; -import { PopperOffset, PopperOffsetFunction, PopperOffsetFunctionParam } from './getPopperOffset'; - -type PlacementPosition = 'top' | 'bottom' | 'left' | 'right'; -type PlacementAlign = 'start' | 'end' | ''; // '' represents center - -const getPositionMap = (rtl?: boolean): Record => ({ - above: 'top', - below: 'bottom', - before: rtl ? 'right' : 'left', - after: rtl ? 'left' : 'right', -}); - -const getAlignmentMap = (rtl?: boolean): Record => ({ - start: rtl ? 'end' : 'start', - end: rtl ? 'start' : 'end', - top: 'start', - bottom: 'end', - center: '', -}); - -const shouldAlignToCenter = (p?: Position, a?: Alignment): boolean => { - const positionedVertically = p === 'above' || p === 'below'; - const alignedVertically = a === 'top' || a === 'bottom'; - - return (positionedVertically && alignedVertically) || (!positionedVertically && !alignedVertically); -}; - -/** - * @see positioninHelper.test.ts for expected placement values - */ -export const getPlacement = (align?: Alignment, position?: Position, rtl?: boolean): PopperJs.Placement => { - const alignment = shouldAlignToCenter(position, align) ? 'center' : align; - - const computedPosition = position && getPositionMap(rtl)[position]; - const computedAlignmnent = alignment && getAlignmentMap(rtl)[alignment]; - - if (computedPosition && computedAlignmnent) { - return `${computedPosition}-${computedAlignmnent}` as PopperJs.Placement; - } - - return computedPosition || ('auto' as PopperJs.Placement); -}; - -export const applyRtlToOffset = (offset: PopperOffset | undefined): PopperOffset | undefined => { - if (typeof offset === 'undefined') { - return undefined; - } - - if (Array.isArray(offset)) { - offset[0] = offset[0]! * -1; - - return offset; - } - - return ((param: PopperOffsetFunctionParam) => applyRtlToOffset(offset(param))) as PopperOffsetFunction; -}; diff --git a/packages/react-components/react-positioning/src/utils/toFloatingUIPlacement.test.ts b/packages/react-components/react-positioning/src/utils/toFloatingUIPlacement.test.ts new file mode 100644 index 00000000000000..43f70bc8333b8f --- /dev/null +++ b/packages/react-components/react-positioning/src/utils/toFloatingUIPlacement.test.ts @@ -0,0 +1,35 @@ +import { toFloatingUIPlacement } from './toFloatingUIPlacement'; +import type { Alignment, Position } from '../types'; + +describe('toFloatingUIPlacement', () => { + it.each([ + //[align, position, placement, rtlPlacement] + ['start', 'above', 'top-start', 'top-start'], + ['center', 'above', 'top', 'top'], + ['end', 'above', 'top-end', 'top-end'], + ['start', 'below', 'bottom-start', 'bottom-start'], + ['center', 'below', 'bottom', 'bottom'], + ['end', 'below', 'bottom-end', 'bottom-end'], + ['top', 'before', 'left-start', 'right-start'], + ['center', 'before', 'left', 'right'], + ['bottom', 'before', 'left-end', 'right-end'], + ['top', 'after', 'right-start', 'left-start'], + ['center', 'after', 'right', 'left'], + ['bottom', 'after', 'right-end', 'left-end'], + [undefined, 'above', 'top', 'top'], + [undefined, 'below', 'bottom', 'bottom'], + [undefined, 'before', 'left', 'right'], + [undefined, 'after', 'right', 'left'], + ])( + 'should use align: "%s" position: "%s" and return LTR placement: "%s" and RTL placement: "%s"', + (align, position, expectedPlacement, expectedRtlPlacement) => { + // Act + const placement = toFloatingUIPlacement(align as Alignment, position as Position); + const rtlPlacement = toFloatingUIPlacement(align as Alignment, position as Position, true); + + // Assert + expect(placement).toEqual(expectedPlacement); + expect(rtlPlacement).toEqual(expectedRtlPlacement); + }, + ); +}); diff --git a/packages/react-components/react-positioning/src/utils/toFloatingUIPlacement.ts b/packages/react-components/react-positioning/src/utils/toFloatingUIPlacement.ts new file mode 100644 index 00000000000000..a70fc97c82595d --- /dev/null +++ b/packages/react-components/react-positioning/src/utils/toFloatingUIPlacement.ts @@ -0,0 +1,46 @@ +import type { Placement, Side, Alignment as FloatingUIAlignment } from '@floating-ui/dom'; +import type { Alignment, Position } from '../types'; + +type PlacementPosition = Side; +type PlacementAlign = FloatingUIAlignment; + +const getPositionMap = (rtl?: boolean): Record => ({ + above: 'top', + below: 'bottom', + before: rtl ? 'right' : 'left', + after: rtl ? 'left' : 'right', +}); + +// Floating UI automatically flips alignment +// https://github.com/floating-ui/floating-ui/issues/1563 +const getAlignmentMap = (): Record => ({ + start: 'start', + end: 'end', + top: 'start', + bottom: 'end', + center: undefined, +}); + +const shouldAlignToCenter = (p?: Position, a?: Alignment): boolean => { + const positionedVertically = p === 'above' || p === 'below'; + const alignedVertically = a === 'top' || a === 'bottom'; + + return (positionedVertically && alignedVertically) || (!positionedVertically && !alignedVertically); +}; + +/** + * Maps internal positioning values to Floating UI placement + * @see positioningHelper.test.ts for expected placement values + */ +export const toFloatingUIPlacement = (align?: Alignment, position?: Position, rtl?: boolean): Placement | undefined => { + const alignment = shouldAlignToCenter(position, align) ? 'center' : align; + + const computedPosition = position && getPositionMap(rtl)[position]; + const computedAlignment = alignment && getAlignmentMap()[alignment]; + + if (computedPosition && computedAlignment) { + return `${computedPosition}-${computedAlignment}` as Placement; + } + + return computedPosition; +}; diff --git a/packages/react-components/react-positioning/src/utils/toggleScrollListener.test.ts b/packages/react-components/react-positioning/src/utils/toggleScrollListener.test.ts new file mode 100644 index 00000000000000..286366bae692e1 --- /dev/null +++ b/packages/react-components/react-positioning/src/utils/toggleScrollListener.test.ts @@ -0,0 +1,39 @@ +import { toggleScrollListener } from './toggleScrollListener'; +describe('toggleScrollListener', () => { + beforeEach(jest.clearAllMocks); + + it('should add event listener for scroll', () => { + const handler = jest.fn(); + toggleScrollListener(document.createElement('div'), null, handler); + document.body.dispatchEvent(new CustomEvent('scroll')); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('should remove previous event listener', () => { + const handler = jest.fn(); + const next = document.createElement('div'); + const prev = document.createElement('div'); + const prevScrollParent = document.createElement('div'); + prevScrollParent.style.overflow = 'scroll'; + prevScrollParent.appendChild(prev); + + toggleScrollListener(prev, null, handler); + toggleScrollListener(next, prev, handler); + prevScrollParent.dispatchEvent(new CustomEvent('scroll')); + + expect(handler).toHaveBeenCalledTimes(0); + }); + + it('should do nothing if previous and next elements are the same', () => { + const handler = jest.fn(); + const element = document.createElement('div'); + const addEventListener = jest.spyOn(document.body, 'addEventListener'); + const removeEventListener = jest.spyOn(document.body, 'removeEventListener'); + toggleScrollListener(element, null, handler); + toggleScrollListener(element, element, handler); + + expect(removeEventListener).toHaveBeenCalledTimes(0); + expect(addEventListener).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react-components/react-positioning/src/utils/toggleScrollListener.ts b/packages/react-components/react-positioning/src/utils/toggleScrollListener.ts new file mode 100644 index 00000000000000..9b507e7be53c3b --- /dev/null +++ b/packages/react-components/react-positioning/src/utils/toggleScrollListener.ts @@ -0,0 +1,27 @@ +import type { PositioningVirtualElement } from '../types'; +import { getScrollParent } from './getScrollParent'; + +/** + * Toggles event listeners for scroll parent. + * Cleans up the event listeners for the previous element and adds them for the new scroll parent. + * @param next Next element + * @param prev Previous element + */ +export function toggleScrollListener( + next: HTMLElement | PositioningVirtualElement | null, + prev: HTMLElement | PositioningVirtualElement | null, + handler: EventListener, +) { + if (next === prev) { + return; + } + + if (prev instanceof HTMLElement) { + const prevScrollParent = getScrollParent(prev); + prevScrollParent.removeEventListener('scroll', handler); + } + if (next instanceof HTMLElement) { + const scrollParent = getScrollParent(next); + scrollParent.addEventListener('scroll', handler); + } +} diff --git a/yarn.lock b/yarn.lock index cbb75e201cbdaf..da43a002c53715 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1645,6 +1645,18 @@ unique-filename "^1.1.1" which "^1.3.1" +"@floating-ui/core@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.0.0.tgz#ec1d31f54c72dd0460276e2149e59bd13c0f01f6" + integrity sha512-sm3nW0hHAxTv3gRDdCH8rNVQxijF+qPFo5gAeXCErRjKC7Qc28lIQ3R9Vd7Gw+KgwfA7RhRydDFuGeI0peGq7A== + +"@floating-ui/dom@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.0.0.tgz#66923a56755b6cb7a5958ecf25fe293912672d65" + integrity sha512-PMqJvY5Fae8HVQgUqM+lidprS6p9LSvB0AUhCdYKqr3YCaV+WaWCeVNBtXPRY2YIdrgcsL2+vd5F07FxgihHUw== + dependencies: + "@floating-ui/core" "^1.0.0" + "@fluent-blocks/colors@9.2.0": version "9.2.0" resolved "https://registry.yarnpkg.com/@fluent-blocks/colors/-/colors-9.2.0.tgz#8aeb30f93f5f827b2842b9cff43541a87e304e18"