diff --git a/apps/ts-minbar-test-react-components/assets/patches/@floating-ui+core+0.6.0.patch b/apps/ts-minbar-test-react-components/assets/patches/@floating-ui+core+0.6.0.patch new file mode 100644 index 0000000000000..9e8d31ca46d7d --- /dev/null +++ b/apps/ts-minbar-test-react-components/assets/patches/@floating-ui+core+0.6.0.patch @@ -0,0 +1,20 @@ +diff --git a/node_modules/@floating-ui/core/src/types.d.ts b/node_modules/@floating-ui/core/src/types.d.ts +index f5c4f1b..14cc9b3 100644 +--- a/node_modules/@floating-ui/core/src/types.d.ts ++++ b/node_modules/@floating-ui/core/src/types.d.ts +@@ -1,6 +1,14 @@ + export declare type Alignment = 'start' | 'end'; + export declare type Side = 'top' | 'right' | 'bottom' | 'left'; +-export declare type AlignedPlacement = `${Side}-${Alignment}`; ++export declare type AlignedPlacement = ++ | 'top-start' ++ | 'top-end' ++ | 'right-start' ++ | 'right-end' ++ | 'bottom-start' ++ | 'bottom-end' ++ | 'left-start' ++ | 'left-end'; + export declare type Placement = Side | AlignedPlacement; + export declare type Strategy = 'absolute' | 'fixed'; + export declare type Axis = 'x' | 'y'; diff --git a/apps/ts-minbar-test-react-components/assets/patches/@floating-ui+dom+0.4.0.patch b/apps/ts-minbar-test-react-components/assets/patches/@floating-ui+dom+0.4.0.patch new file mode 100644 index 0000000000000..99e6df19bebdc --- /dev/null +++ b/apps/ts-minbar-test-react-components/assets/patches/@floating-ui+dom+0.4.0.patch @@ -0,0 +1,7 @@ +diff --git a/node_modules/@floating-ui/dom/src/utils/getOverflowAncestors.d.ts b/node_modules/@floating-ui/dom/src/utils/getOverflowAncestors.d.ts +index 461f170..86d7a4a 100644 +--- a/node_modules/@floating-ui/dom/src/utils/getOverflowAncestors.d.ts ++++ b/node_modules/@floating-ui/dom/src/utils/getOverflowAncestors.d.ts +@@ -1 +1 @@ +-export declare function getOverflowAncestors(node: Node, list?: Array): Array; ++export declare function getOverflowAncestors(node: Node, list?: Array): Array; diff --git a/apps/ts-minbar-test-react-components/assets/patches/README.md b/apps/ts-minbar-test-react-components/assets/patches/README.md new file mode 100644 index 0000000000000..ff4aff25b956b --- /dev/null +++ b/apps/ts-minbar-test-react-components/assets/patches/README.md @@ -0,0 +1,5 @@ +# Patches for TS minbar test + +Some partners might be on incompatible versions of typescript, but are willing to patch incompatible +types on their end. If this is the case, simply create a similar patch for the affected code with +[patch-package](https://github.com/ds300/patch-package) and drop the resulting patch in this folder. diff --git a/apps/ts-minbar-test-react-components/src/index.ts b/apps/ts-minbar-test-react-components/src/index.ts index c2249c1dd9062..6f7794ea5ccff 100644 --- a/apps/ts-minbar-test-react-components/src/index.ts +++ b/apps/ts-minbar-test-react-components/src/index.ts @@ -27,6 +27,7 @@ async function performTest() { 'react@17', 'react-dom@17', `typescript@${tsVersion}`, + 'patch-package', ].join(' '); await shEcho(`yarn add ${dependencies}`, tempPaths.testApp); logger(`✔️ Dependencies were installed`); @@ -41,6 +42,11 @@ async function performTest() { fs.mkdirSync(path.join(tempPaths.testApp, 'src')); fs.copyFileSync(scaffoldPath('index.tsx'), path.join(tempPaths.testApp, 'src/index.tsx')); fs.copyFileSync(scaffoldPath('tsconfig.json'), path.join(tempPaths.testApp, 'tsconfig.json')); + + fs.mkdirSync(path.join(tempPaths.testApp, 'patches/')); + fs.copySync(scaffoldPath('patches/'), path.join(tempPaths.testApp, 'patches/')); + await shEcho(`yarn patch-package`, tempPaths.testApp); + logger(`✔️ Source and configs were copied`); await shEcho(`npx npm-which yarn`); diff --git a/apps/vr-tests-react-components/src/stories/Positioning.stories.tsx b/apps/vr-tests-react-components/src/stories/Positioning.stories.tsx index 011ad534f042e..093800c346691 100644 --- a/apps/vr-tests-react-components/src/stories/Positioning.stories.tsx +++ b/apps/vr-tests-react-components/src/stories/Positioning.stories.tsx @@ -1,10 +1,10 @@ import * as React from 'react'; import { - usePopper, + usePositioning, createArrowStyles, PositioningProps, - PopperVirtualElement, - PopperRefHandle, + PositioningVirtualElement, + PositioningImperativeRef, } from '@fluentui/react-positioning'; import { makeStyles, mergeClasses, shorthands } from '@griffel/react'; import { useMergedRefs } from '@fluentui/react-utilities'; @@ -103,7 +103,7 @@ const Box = React.forwardRef = ({ positionFixed }) => { const styles = useStyles(); - const positionedRefs = positions.reduce[]>((acc, cur) => { + const positionedRefs = positions.reduce[]>((acc, cur) => { const popperOptions: PositioningProps = { position: cur[0], align: cur[1] }; // positionFixed is not public yet // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -112,8 +112,8 @@ const PositionAndAlignProps: React.FC<{ positionFixed?: boolean }> = ({ position // this loop is deterministic // eslint-disable-next-line react-hooks/rules-of-hooks - const popperRefs = usePopper(popperOptions); - acc.push(popperRefs); + const positioningRefs = usePositioning(popperOptions); + acc.push(positioningRefs); return acc; }, []); @@ -131,11 +131,15 @@ const PositionAndAlignProps: React.FC<{ positionFixed?: boolean }> = ({ position const Offset = () => { const styles = useStyles(); - const positionedRefs = positions.reduce[]>((acc, cur) => { + const positionedRefs = positions.reduce[]>((acc, cur) => { // this loop is deterministic // eslint-disable-next-line react-hooks/rules-of-hooks - const popperRefs = usePopper({ position: cur[0], align: cur[1], offset: [10, 10] }); - acc.push(popperRefs); + const positioningRefs = usePositioning({ + position: cur[0], + align: cur[1], + offset: { crossAxis: 10, mainAxis: 10 }, + }); + acc.push(positioningRefs); return acc; }, []); @@ -153,11 +157,15 @@ const Offset = () => { const OffsetFunction = () => { const styles = useStyles(); - const positionedRefs = positions.reduce[]>((acc, cur) => { + const positionedRefs = positions.reduce[]>((acc, cur) => { // this loop is deterministic // eslint-disable-next-line react-hooks/rules-of-hooks - const popperRefs = usePopper({ position: cur[0], align: cur[1], offset: () => [10, 10] }); - acc.push(popperRefs); + const positioningRefs = usePositioning({ + position: cur[0], + align: cur[1], + offset: () => ({ crossAxis: 10, mainAxis: 10 }), + }); + acc.push(positioningRefs); return acc; }, []); @@ -175,11 +183,11 @@ const OffsetFunction = () => { const CoverTarget = () => { const styles = useStyles(); - const positionedRefs = positions.reduce[]>((acc, cur) => { + const positionedRefs = positions.reduce[]>((acc, cur) => { // this loop is deterministic // eslint-disable-next-line react-hooks/rules-of-hooks - const popperRefs = usePopper({ position: cur[0], align: cur[1], coverTarget: true }); - acc.push(popperRefs); + const positioningRefs = usePositioning({ position: cur[0], align: cur[1], coverTarget: true }); + acc.push(positioningRefs); return acc; }, []); @@ -198,8 +206,8 @@ const CoverTarget = () => { const VerticalFlip = () => { const styles = useStyles(); const [boundary, setBoundary] = React.useState(null); - const topPopper = usePopper({ position: 'above', flipBoundary: boundary ?? undefined }); - const bottomPopper = usePopper({ position: 'below', flipBoundary: boundary ?? undefined }); + const topPopper = usePositioning({ position: 'above', flipBoundary: boundary ?? undefined }); + const bottomPopper = usePositioning({ position: 'below', flipBoundary: boundary ?? undefined }); return (
{ const HorizontalFlip = () => { const styles = useStyles(); const [boundary, setBoundary] = React.useState(null); - const startPopper = usePopper({ position: 'before', flipBoundary: boundary ?? undefined }); - const endPopper = usePopper({ position: 'after', flipBoundary: boundary ?? undefined }); + const startPopper = usePositioning({ position: 'before', flipBoundary: boundary ?? undefined }); + const endPopper = usePositioning({ position: 'after', flipBoundary: boundary ?? undefined }); const { dir } = useFluent(); const marginDir = dir === 'ltr' ? 'marginLeft' : 'marginRight'; @@ -242,8 +250,8 @@ const HorizontalFlip = () => { const VerticalOverflow = () => { const styles = useStyles(); const [boundary, setBoundary] = React.useState(null); - const topPopper = usePopper({ position: 'after', overflowBoundary: boundary ?? undefined }); - const bottomPopper = usePopper({ position: 'after', overflowBoundary: boundary ?? undefined }); + const topPopper = usePositioning({ position: 'after', overflowBoundary: boundary ?? undefined }); + const bottomPopper = usePositioning({ position: 'after', overflowBoundary: boundary ?? undefined }); return (
{ const HorizontalOverflow = () => { const styles = useStyles(); const [boundary, setBoundary] = React.useState(null); - const startPopper = usePopper({ position: 'below', overflowBoundary: boundary ?? undefined }); - const endPopper = usePopper({ position: 'below', overflowBoundary: boundary ?? undefined }); + const startPopper = usePositioning({ position: 'below', overflowBoundary: boundary ?? undefined }); + const endPopper = usePositioning({ position: 'below', overflowBoundary: boundary ?? undefined }); const { dir } = useFluent(); const marginDir = dir === 'ltr' ? 'marginLeft' : 'marginRight'; @@ -290,7 +298,7 @@ const HorizontalOverflow = () => { const Pinned = () => { const styles = useStyles(); const [boundary, setBoundary] = React.useState(null); - const { containerRef, targetRef } = usePopper({ + const { containerRef, targetRef } = usePositioning({ position: 'above', flipBoundary: boundary ?? undefined, pinned: true, @@ -310,11 +318,11 @@ const Pinned = () => { const Arrow: React.FC = () => { const styles = useStyles(); - const positionedRefs = positions.reduce[]>((acc, cur) => { + const positionedRefs = positions.reduce[]>((acc, cur) => { // this loop is deterministic // eslint-disable-next-line react-hooks/rules-of-hooks - const popperRefs = usePopper({ position: cur[0], align: cur[1] }); - acc.push(popperRefs); + const positioningRefs = usePositioning({ position: cur[0], align: cur[1] }); + acc.push(positioningRefs); return acc; }, []); @@ -335,7 +343,7 @@ const Arrow: React.FC = () => { const AutoSize = () => { const styles = useStyles(); - const { containerRef, targetRef } = usePopper({ + const { containerRef, targetRef } = usePositioning({ position: 'below', autoSize: true, }); @@ -375,17 +383,20 @@ const AutoSize = () => { const DisableTether = () => { const styles = useStyles(); - const { containerRef, targetRef } = usePopper({ + const [boundary, setBoundary] = React.useState(null); + const { containerRef, targetRef } = usePositioning({ position: 'above', // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore // eslint-disable-next-line @typescript-eslint/naming-convention unstable_disableTether: 'all', + overflowBoundary: boundary, }); return ( <>
{ position: 'relative', }} > - Untethered @@ -411,14 +422,14 @@ const DisableTether = () => { const VirtualElement = () => { const [target, setTarget] = React.useState(null); - const { containerRef, targetRef } = usePopper({ + const { containerRef, targetRef } = usePositioning({ position: 'below', align: 'end', }); React.useEffect(() => { if (target) { - const virtualElement: PopperVirtualElement = { + const virtualElement: PositioningVirtualElement = { getBoundingClientRect: () => target.getBoundingClientRect(), }; @@ -437,7 +448,7 @@ const VirtualElement = () => { const TargetProp = () => { const [target, setTarget] = React.useState(null); - const { containerRef } = usePopper({ + const { containerRef } = usePositioning({ target, position: 'below', align: 'end', @@ -452,18 +463,18 @@ const TargetProp = () => { }; const ImperativeTarget = () => { - const popperRef = React.useRef({ updatePosition: () => null, setTarget: () => null }); + const imperativeRef = React.useRef({ updatePosition: () => null, setTarget: () => null }); const ref = React.useRef(null); - const { containerRef } = usePopper({ - popperRef, + const { containerRef } = usePositioning({ + imperativeRef, position: 'below', align: 'end', }); React.useEffect(() => { if (ref.current) { - popperRef.current.setTarget(ref.current); + imperativeRef.current.setTarget(ref.current); } }, []); @@ -477,7 +488,7 @@ const ImperativeTarget = () => { const VisibilityModifiers = () => { const styles = useStyles(); - const popper = usePopper({ align: 'center', position: 'above' }); + const popper = usePositioning({ align: 'center', position: 'above' }); return ( <> diff --git a/change/@fluentui-react-menu-16ea1e78-a70f-4378-9515-49a0254f3217.json b/change/@fluentui-react-menu-16ea1e78-a70f-4378-9515-49a0254f3217.json new file mode 100644 index 0000000000000..2ded2e27b875f --- /dev/null +++ b/change/@fluentui-react-menu-16ea1e78-a70f-4378-9515-49a0254f3217.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "BREAKING: Updates related to internal bump of Popper.js to Floating UI", + "packageName": "@fluentui/react-menu", + "email": "lingfangao@hotmail.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-popover-7c1b5c26-eb72-4d8b-8951-f6a5501eb7af.json b/change/@fluentui-react-popover-7c1b5c26-eb72-4d8b-8951-f6a5501eb7af.json new file mode 100644 index 0000000000000..57bac6e32962d --- /dev/null +++ b/change/@fluentui-react-popover-7c1b5c26-eb72-4d8b-8951-f6a5501eb7af.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "BREAKING: Updates related to internal bump of Popper.js to Floating UI", + "packageName": "@fluentui/react-popover", + "email": "lingfangao@hotmail.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-positioning-deff0906-26f7-49f7-8e84-fd138709d245.json b/change/@fluentui-react-positioning-deff0906-26f7-49f7-8e84-fd138709d245.json new file mode 100644 index 0000000000000..25f4bc000a068 --- /dev/null +++ b/change/@fluentui-react-positioning-deff0906-26f7-49f7-8e84-fd138709d245.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "BREAKING: Upgrade PopperJS for FloatingUI", + "packageName": "@fluentui/react-positioning", + "email": "lingfangao@hotmail.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-tooltip-3f810af7-b574-4988-bf02-21dbdde3e0db.json b/change/@fluentui-react-tooltip-3f810af7-b574-4988-bf02-21dbdde3e0db.json new file mode 100644 index 0000000000000..b6fc77744ca60 --- /dev/null +++ b/change/@fluentui-react-tooltip-3f810af7-b574-4988-bf02-21dbdde3e0db.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "BREAKING: Updates related to internal bump of Popper.js to Floating UI", + "packageName": "@fluentui/react-tooltip", + "email": "lingfangao@hotmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-combobox/src/components/Combobox/useCombobox.ts b/packages/react-combobox/src/components/Combobox/useCombobox.ts index d7c0f7f024cd5..033aaf8cfef14 100644 --- a/packages/react-combobox/src/components/Combobox/useCombobox.ts +++ b/packages/react-combobox/src/components/Combobox/useCombobox.ts @@ -1,5 +1,5 @@ import * as React from 'react'; -import { resolvePositioningShorthand, usePopper } from '@fluentui/react-positioning'; +import { resolvePositioningShorthand, usePositioning } from '@fluentui/react-positioning'; import { getPartitionedNativeProps, resolveShorthand, @@ -56,7 +56,7 @@ export const useCombobox_unstable = (props: ComboboxProps, ref: React.Ref; containerRef: React.MutableRefObject; - } = usePopper(popperOptions); + } = usePositioning(popperOptions); // update value based on selectedOptions const isFirstMount = useFirstMount(); diff --git a/packages/react-components/src/Concepts/Positioning/PositioningCoverTarget.stories.tsx b/packages/react-components/src/Concepts/Positioning/PositioningCoverTarget.stories.tsx index 417244df2fdad..2b0d085fa322b 100644 --- a/packages/react-components/src/Concepts/Positioning/PositioningCoverTarget.stories.tsx +++ b/packages/react-components/src/Concepts/Positioning/PositioningCoverTarget.stories.tsx @@ -221,7 +221,7 @@ CoverTarget.parameters = { CoverTarget.decorators = [ (Story: React.ElementType) => ( -
+
), diff --git a/packages/react-components/src/Concepts/Positioning/PositioningImperativeAnchorTarget.stories.tsx b/packages/react-components/src/Concepts/Positioning/PositioningImperativeAnchorTarget.stories.tsx index feb5e4554f189..f44360aeea3d3 100644 --- a/packages/react-components/src/Concepts/Positioning/PositioningImperativeAnchorTarget.stories.tsx +++ b/packages/react-components/src/Concepts/Positioning/PositioningImperativeAnchorTarget.stories.tsx @@ -1,9 +1,9 @@ import * as React from 'react'; import { Tooltip } from '@fluentui/react-components'; -import { PopperRefHandle, PopperVirtualElement } from '@fluentui/react-positioning'; +import { PositioningImperativeRef, PositioningVirtualElement } from '@fluentui/react-positioning'; export const ImperativeAnchorTarget = () => { - const popperRef = React.useRef(null); + const imperativeRef = React.useRef(null); const [open, setOpen] = React.useState(false); const onMouseMove = React.useCallback((e: React.MouseEvent) => { @@ -19,10 +19,10 @@ export const ImperativeAnchorTarget = () => { y, }); }; - const virtualElement: PopperVirtualElement = { + const virtualElement: PositioningVirtualElement = { getBoundingClientRect: getRect(e.clientX, e.clientY), }; - popperRef.current?.setTarget(virtualElement); + imperativeRef.current?.setTarget(virtualElement); }, []); const onMouseEnter = React.useCallback(() => { @@ -40,7 +40,7 @@ export const ImperativeAnchorTarget = () => { <> @@ -68,7 +68,7 @@ ImperativeAnchorTarget.parameters = { docs: { description: { story: [ - 'The `popperRef` positioning prop provides an [imperative handle](https://reactjs.org/docs/hooks-reference.html#useimperativehandle)', + 'The `imperativeRef` positioning prop provides an [imperative handle](https://reactjs.org/docs/hooks-reference.html#useimperativehandle)', 'to manually position an element. The target can be a normal HTML element or a virtual element such as a', 'coordinate on the viewport', 'This can be useful to reduce the number of renders required, for example when the positioned element', diff --git a/packages/react-components/src/Concepts/Positioning/PositioningImperativePositionUpdate.stories.tsx b/packages/react-components/src/Concepts/Positioning/PositioningImperativePositionUpdate.stories.tsx index 4669ebacd4aed..a54671a00f14e 100644 --- a/packages/react-components/src/Concepts/Positioning/PositioningImperativePositionUpdate.stories.tsx +++ b/packages/react-components/src/Concepts/Positioning/PositioningImperativePositionUpdate.stories.tsx @@ -1,11 +1,11 @@ import * as React from 'react'; import { Popover, PopoverTrigger, PopoverSurface, PopoverProps } from '@fluentui/react-popover'; import { Button } from '@fluentui/react-button'; -import { PopperRefHandle } from '@fluentui/react-positioning'; +import { PositioningImperativeRef } from '@fluentui/react-positioning'; export const ImperativePositionUpdate = () => { const [loading, setLoading] = React.useState(true); - const popperRef = React.useRef(null); + const imperativeRef = React.useRef(null); const timeoutRef = React.useRef(0); const onOpenChange = React.useCallback>((e, data) => { @@ -19,7 +19,7 @@ export const ImperativePositionUpdate = () => { React.useEffect(() => { if (!loading) { - popperRef.current?.updatePosition(); + imperativeRef.current?.updatePosition(); } }, [loading]); @@ -28,7 +28,7 @@ export const ImperativePositionUpdate = () => { }); return ( - + @@ -43,7 +43,7 @@ ImperativePositionUpdate.parameters = { docs: { description: { story: [ - 'The `popperRef` positioning prop provides an [imperative handle](https://reactjs.org/docs/hooks-reference.html#useimperativehandle)', + 'The `imperativeRef` positioning prop provides an [imperative handle](https://reactjs.org/docs/hooks-reference.html#useimperativehandle)', 'to reposition the positioned element. This can be useful for scenarios where content is dynamically loaded.', '', 'In this example, you can move your mouse in the red boundary and the tooltip will follow the mouse cursor', diff --git a/packages/react-components/src/Concepts/Positioning/PositioningOffsetFunction.stories.tsx b/packages/react-components/src/Concepts/Positioning/PositioningOffsetFunction.stories.tsx index 1bc296aaa2388..4a4b5da05fceb 100644 --- a/packages/react-components/src/Concepts/Positioning/PositioningOffsetFunction.stories.tsx +++ b/packages/react-components/src/Concepts/Positioning/PositioningOffsetFunction.stories.tsx @@ -4,8 +4,8 @@ import { Button } from '@fluentui/react-button'; import { PositioningProps } from '@fluentui/react-positioning'; export const OffsetFunction = () => { - const offset: PositioningProps['offset'] = ({ popper, reference, placement }) => { - return [10, popper.width / 2]; + const offset: PositioningProps['offset'] = ({ positioned, target, position, alignment }) => { + return { crossAxis: 10, mainAxis: positioned.width / 2 }; }; return ( @@ -25,11 +25,11 @@ OffsetFunction.parameters = { description: { story: [ 'The positioned element can be offset from the target element by using a callback function.', - 'The callback function provides the arguments and are a values used directly by Popper.', + 'The callback function provides the arguments and are a values used directly by Floating UI.', '', '- Dimensions and position of the positioned element', '- Dimensions and position of the reference element', - '- The Popper.JS placement value', + '- The Floating UI placement value', ].join('\n'), }, }, diff --git a/packages/react-components/src/Concepts/Positioning/PositioningOffsetValue.stories.tsx b/packages/react-components/src/Concepts/Positioning/PositioningOffsetValue.stories.tsx index 8d3b04814bbfd..39759231e4f1a 100644 --- a/packages/react-components/src/Concepts/Positioning/PositioningOffsetValue.stories.tsx +++ b/packages/react-components/src/Concepts/Positioning/PositioningOffsetValue.stories.tsx @@ -3,34 +3,27 @@ import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-popover import { Button } from '@fluentui/react-button'; export const OffsetValue = () => { - const [offsetY, setOffsetY] = React.useState(10); - const [offsetX, setOffsetX] = React.useState(10); + const [crossAxis, setCrossAxis] = React.useState(10); + const [mainAxis, setMainAxis] = React.useState(10); const onChangeY = (e: React.ChangeEvent) => { - setOffsetY(parseInt(e.target.value, 10)); + setCrossAxis(parseInt(e.target.value, 10)); }; const onChangeX = (e: React.ChangeEvent) => { - setOffsetX(parseInt(e.target.value, 10)); + setMainAxis(parseInt(e.target.value, 10)); }; return (
- - - - - - Container - - [offsetY, offsetX] }} noArrow> + @@ -47,9 +40,16 @@ OffsetValue.parameters = { description: { story: [ 'The positioned element can be offset from the target element. The offset value can be set either by:', + 'Offset is determined by', + '', + '- Cross axis: The distance the positioning element slides from the target', + '- Main axis: The distance between positioning element and the target', + '', + 'Offset is determined by', + '', + '- An object with cross axis and main axis values', + '- A function that returns the offset object', '', - '- Simple array with X and Y axis values', - '- A function that returns the array offset value', ].join('\n'), }, }, diff --git a/packages/react-components/src/Concepts/Positioning/PositioningShorthandPositions.stories.tsx b/packages/react-components/src/Concepts/Positioning/PositioningShorthandPositions.stories.tsx index 7108e8adefa47..e1c5fabf4d0c3 100644 --- a/packages/react-components/src/Concepts/Positioning/PositioningShorthandPositions.stories.tsx +++ b/packages/react-components/src/Concepts/Positioning/PositioningShorthandPositions.stories.tsx @@ -188,7 +188,7 @@ ShorthandPositions.parameters = { ShorthandPositions.decorators = [ (Story: React.ElementType) => ( -
+
), diff --git a/packages/react-menu/etc/react-menu.api.md b/packages/react-menu/etc/react-menu.api.md index 147865a0e0b98..6a7d2ada10c95 100644 --- a/packages/react-menu/etc/react-menu.api.md +++ b/packages/react-menu/etc/react-menu.api.md @@ -14,7 +14,7 @@ import { PositioningShorthand } from '@fluentui/react-positioning'; import * as React_2 from 'react'; import type { Slot } from '@fluentui/react-utilities'; import type { SlotClassNames } from '@fluentui/react-utilities'; -import { usePopperMouseTarget } from '@fluentui/react-positioning'; +import { useMouseTarget } from '@fluentui/react-positioning'; // @public export const Menu: React_2.FC; @@ -300,8 +300,8 @@ export type MenuState = MenuCommons & ComponentState & { triggerRef: React_2.MutableRefObject; triggerId: string; isSubmenu: boolean; - contextTarget: ReturnType[0]; - setContextTarget: ReturnType[1]; + contextTarget: ReturnType[0]; + setContextTarget: ReturnType[1]; }; // @public diff --git a/packages/react-menu/src/components/Menu/Menu.types.ts b/packages/react-menu/src/components/Menu/Menu.types.ts index 4695a998d1380..54339ed2f3cea 100644 --- a/packages/react-menu/src/components/Menu/Menu.types.ts +++ b/packages/react-menu/src/components/Menu/Menu.types.ts @@ -1,6 +1,6 @@ import * as React from 'react'; import { ComponentProps, ComponentState } from '@fluentui/react-utilities'; -import { usePopperMouseTarget, PositioningShorthand } from '@fluentui/react-positioning'; +import { useMouseTarget, PositioningShorthand } from '@fluentui/react-positioning'; import { MenuListCommons } from '../MenuList/MenuList.types'; import { MenuContextValue } from '../../contexts/menuContext'; @@ -107,12 +107,12 @@ export type MenuState = MenuCommons & /** * Anchors the popper to the mouse click for context events */ - contextTarget: ReturnType[0]; + contextTarget: ReturnType[0]; /** * A callback to set the target of the popper to the mouse click for context events */ - setContextTarget: ReturnType[1]; + setContextTarget: ReturnType[1]; }; /** diff --git a/packages/react-menu/src/components/Menu/useMenu.tsx b/packages/react-menu/src/components/Menu/useMenu.tsx index 7ef6cfef60e3f..1c0068377a040 100644 --- a/packages/react-menu/src/components/Menu/useMenu.tsx +++ b/packages/react-menu/src/components/Menu/useMenu.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { usePopperMouseTarget, usePopper, resolvePositioningShorthand } from '@fluentui/react-positioning'; +import { useMouseTarget, usePositioning, resolvePositioningShorthand } from '@fluentui/react-positioning'; import { useControllableState, useId, useOnClickOutside, useEventCallback } from '@fluentui/react-utilities'; import { useFluent } from '@fluentui/react-shared-contexts'; import { elementContains } from '@fluentui/react-portal'; @@ -20,7 +20,7 @@ import type { MenuOpenChangeData, MenuOpenEvents, MenuProps, MenuState } from '. export const useMenu_unstable = (props: MenuProps): MenuState => { const triggerId = useId('menu'); const isSubmenu = useIsSubmenu(); - const [contextTarget, setContextTarget] = usePopperMouseTarget(); + const [contextTarget, setContextTarget] = useMouseTarget(); const popperState = { position: isSubmenu ? ('after' as const) : ('below' as const), @@ -51,7 +51,7 @@ export const useMenu_unstable = (props: MenuProps): MenuState => { } else if (children.length === 1) { menuPopover = children[0]; } - const { targetRef: triggerRef, containerRef: menuPopoverRef } = usePopper(popperState); + const { targetRef: triggerRef, containerRef: menuPopoverRef } = usePositioning(popperState); const initialState = { hoverDelay: 500, diff --git a/packages/react-menu/src/stories/MenuAnchorToTarget.stories.tsx b/packages/react-menu/src/stories/MenuAnchorToTarget.stories.tsx index 0259f817d5efd..aca3a233625fb 100644 --- a/packages/react-menu/src/stories/MenuAnchorToTarget.stories.tsx +++ b/packages/react-menu/src/stories/MenuAnchorToTarget.stories.tsx @@ -3,11 +3,11 @@ import * as React from 'react'; import { Menu, MenuList, MenuItem, MenuPopover, MenuProps } from '../index'; import { Button } from '@fluentui/react-button'; -import { PopperRefHandle } from '@fluentui/react-positioning'; +import { PositioningImperativeRef } from '@fluentui/react-positioning'; export const AnchorToCustomTarget = () => { const buttonRef = React.useRef(null); - const popperRef = React.useRef(null); + const imperativeRef = React.useRef(null); const [open, setOpen] = React.useState(false); const onOpenChange: MenuProps['onOpenChange'] = (e, data) => { setOpen(data.open); @@ -15,9 +15,9 @@ export const AnchorToCustomTarget = () => { React.useEffect(() => { if (buttonRef.current) { - popperRef.current?.setTarget(buttonRef.current); + imperativeRef.current?.setTarget(buttonRef.current); } - }, [buttonRef, popperRef]); + }, [buttonRef, imperativeRef]); return ( <> @@ -25,7 +25,7 @@ export const AnchorToCustomTarget = () => { - + New diff --git a/packages/react-popover/etc/react-popover.api.md b/packages/react-popover/etc/react-popover.api.md index c10e8e9d3d45b..48ddeca16186b 100644 --- a/packages/react-popover/etc/react-popover.api.md +++ b/packages/react-popover/etc/react-popover.api.md @@ -11,14 +11,14 @@ import type { ContextSelector } from '@fluentui/react-context-selector'; import type { FluentTriggerComponent } from '@fluentui/react-utilities'; import type { ForwardRefComponent } from '@fluentui/react-utilities'; import { JSXElementConstructor } from 'react'; -import type { PopperVirtualElement } from '@fluentui/react-positioning'; import type { PortalProps } from '@fluentui/react-portal'; import type { PositioningShorthand } from '@fluentui/react-positioning'; +import type { PositioningVirtualElement } from '@fluentui/react-positioning'; import * as React_2 from 'react'; import { ReactElement } from 'react'; import type { Slot } from '@fluentui/react-utilities'; import type { SlotClassNames } from '@fluentui/react-utilities'; -import type { usePopperMouseTarget } from '@fluentui/react-positioning'; +import type { useMouseTarget } from '@fluentui/react-positioning'; // @public (undocumented) export const arrowHeights: Record; @@ -55,8 +55,8 @@ export type PopoverState = PopoverCommons & Pick & { triggerRef: React_2.MutableRefObject; contentRef: React_2.MutableRefObject; arrowRef: React_2.MutableRefObject; - contextTarget: PopperVirtualElement | undefined; - setContextTarget: ReturnType[1]; + contextTarget: PositioningVirtualElement | undefined; + setContextTarget: ReturnType[1]; size: NonNullable; popoverTrigger: React_2.ReactElement | undefined; popoverSurface: React_2.ReactElement | undefined; diff --git a/packages/react-popover/src/components/Popover/Popover.types.ts b/packages/react-popover/src/components/Popover/Popover.types.ts index aa4a724d8cbfb..cc69054c294f9 100644 --- a/packages/react-popover/src/components/Popover/Popover.types.ts +++ b/packages/react-popover/src/components/Popover/Popover.types.ts @@ -1,5 +1,5 @@ import * as React from 'react'; -import type { PopperVirtualElement, PositioningShorthand, usePopperMouseTarget } from '@fluentui/react-positioning'; +import type { PositioningVirtualElement, PositioningShorthand, useMouseTarget } from '@fluentui/react-positioning'; import type { PortalProps } from '@fluentui/react-portal'; /** @@ -95,11 +95,11 @@ export type PopoverState = PopoverCommons & /** * Anchors the popper to the mouse click for context events */ - contextTarget: PopperVirtualElement | undefined; + contextTarget: PositioningVirtualElement | undefined; /** * A callback to set the target of the popper to the mouse click for context events */ - setContextTarget: ReturnType[1]; + setContextTarget: ReturnType[1]; size: NonNullable; diff --git a/packages/react-popover/src/components/Popover/usePopover.ts b/packages/react-popover/src/components/Popover/usePopover.ts index 9147d18150b4e..10d0068c93a06 100644 --- a/packages/react-popover/src/components/Popover/usePopover.ts +++ b/packages/react-popover/src/components/Popover/usePopover.ts @@ -7,10 +7,10 @@ import { } from '@fluentui/react-utilities'; import { useFluent } from '@fluentui/react-shared-contexts'; import { - usePopper, + usePositioning, resolvePositioningShorthand, mergeArrowOffset, - usePopperMouseTarget, + useMouseTarget, } from '@fluentui/react-positioning'; import { elementContains } from '@fluentui/react-portal'; import { useFocusFinders } from '@fluentui/react-tabster'; @@ -26,7 +26,7 @@ import type { OpenPopoverEvents, PopoverProps, PopoverState } from './Popover.ty * @param props - props from this instance of Popover */ export const usePopover_unstable = (props: PopoverProps): PopoverState => { - const [contextTarget, setContextTarget] = usePopperMouseTarget(); + const [contextTarget, setContextTarget] = useMouseTarget(); const initialState = { size: 'medium', contextTarget, @@ -169,7 +169,7 @@ function usePopoverRefs( popperOptions.offset = mergeArrowOffset(popperOptions.offset, arrowHeights[state.size]); } - const { targetRef: triggerRef, containerRef: contentRef, arrowRef } = usePopper(popperOptions); + const { targetRef: triggerRef, containerRef: contentRef, arrowRef } = usePositioning(popperOptions); return { triggerRef, diff --git a/packages/react-popover/src/stories/PopoverAnchorToCustomTarget.stories.tsx b/packages/react-popover/src/stories/PopoverAnchorToCustomTarget.stories.tsx index 5dd9890a6fe04..ce90f8a1f6c1e 100644 --- a/packages/react-popover/src/stories/PopoverAnchorToCustomTarget.stories.tsx +++ b/packages/react-popover/src/stories/PopoverAnchorToCustomTarget.stories.tsx @@ -3,7 +3,7 @@ import { Button } from '@fluentui/react-button'; import { makeStyles, shorthands } from '@griffel/react'; import { Popover, PopoverTrigger, PopoverSurface } from '../index'; -import { PopperRefHandle } from '@fluentui/react-positioning'; +import { PositioningImperativeRef } from '@fluentui/react-positioning'; const useStyles = makeStyles({ container: { @@ -29,18 +29,18 @@ const ExampleContent = () => { export const AnchorToCustomTarget = () => { const buttonRef = React.useRef(null); - const popperRef = React.useRef(null); + const imperativeRef = React.useRef(null); const styles = useStyles(); React.useEffect(() => { if (buttonRef.current) { - popperRef.current?.setTarget(buttonRef.current); + imperativeRef.current?.setTarget(buttonRef.current); } - }, [buttonRef, popperRef]); + }, [buttonRef, imperativeRef]); return (
- + diff --git a/packages/react-positioning/etc/react-positioning.api.md b/packages/react-positioning/etc/react-positioning.api.md index fa41fb518e3c5..ba6900503f4ba 100644 --- a/packages/react-positioning/etc/react-positioning.api.md +++ b/packages/react-positioning/etc/react-positioning.api.md @@ -4,9 +4,11 @@ ```ts +import { Boundary as Boundary_2 } from '@floating-ui/dom'; import type { GriffelStyle } from '@griffel/react'; -import * as PopperJs from '@popperjs/core'; import * as React_2 from 'react'; +import { Rect } from '@floating-ui/dom'; +import { VirtualElement } from '@floating-ui/dom'; // @public (undocumented) export type Alignment = 'top' | 'bottom' | 'start' | 'end' | 'center'; @@ -15,7 +17,7 @@ export type Alignment = 'top' | 'bottom' | 'start' | 'end' | 'center'; export type AutoSize = 'height' | 'height-always' | 'width' | 'width-always' | 'always' | boolean; // @public (undocumented) -export type Boundary = PopperJs.Boundary | 'scrollParent' | 'window'; +export type Boundary = Boundary_2 | 'scrollParent' | 'window'; // @public export function createArrowHeightStyles(arrowHeight: number): { @@ -27,63 +29,59 @@ export function createArrowHeightStyles(arrowHeight: number): { export function createArrowStyles(options: CreateArrowStylesOptions): GriffelStyle; // @public -export type CreateArrowStylesOptions = { - arrowHeight: number | undefined; - borderWidth?: GriffelStyle['borderBottomWidth']; - borderStyle?: GriffelStyle['borderBottomStyle']; - borderColor?: GriffelStyle['borderBottomColor']; -}; - -// @public -export function createVirtualElementFromClick(nativeEvent: MouseEvent): PopperVirtualElement; - -// @public -export function mergeArrowOffset(userOffset: Offset | undefined | null, arrowHeight: number): Offset; - -// @public (undocumented) -export type Offset = OffsetFunction | [number | null | undefined, number | null | undefined]; +export function createVirtualElementFromClick(nativeEvent: MouseEvent): PositioningVirtualElement; // @public (undocumented) -export type OffsetFunction = (param: OffsetFunctionParam) => [number | null | undefined, number | null | undefined]; - -// @public (undocumented) -export type OffsetFunctionParam = { - popper: PopperJs.Rect; - reference: PopperJs.Rect; - placement: PopperJs.Placement; -}; - -// @public (undocumented) -export interface PopperOptions { +export interface FloatingUIOptions { align?: Alignment; arrowPadding?: number; autoSize?: AutoSize; coverTarget?: boolean; - flipBoundary?: Boundary; + flipBoundary?: Boundary | null; offset?: Offset; - overflowBoundary?: Boundary; + overflowBoundary?: Boundary | null; pinned?: boolean; position?: Position; positionFixed?: boolean; unstable_disableTether?: boolean | 'all'; } +// @public +export function mergeArrowOffset(userOffset: Offset | undefined | null, arrowHeight: number): Offset; + // @public (undocumented) -export type PopperRefHandle = { - updatePosition: () => void; - setTarget: (target: HTMLElement | PopperVirtualElement) => void; +export type Offset = OffsetFunction | OffsetObject | number; + +// @public (undocumented) +export type OffsetFunction = (param: OffsetFunctionParam) => OffsetObject | number; + +// @public (undocumented) +export type OffsetFunctionParam = { + positioned: Rect; + target: Rect; + position: Position; + alignment?: Alignment; }; // @public (undocumented) -export type PopperVirtualElement = PopperJs.VirtualElement; +export type OffsetObject = { + mainAxis?: number; + crossAxis?: number; +}; // @public (undocumented) export type Position = 'above' | 'below' | 'before' | 'after'; // @public (undocumented) -export interface PositioningProps extends Omit { - popperRef?: React_2.Ref; - target?: HTMLElement | PopperVirtualElement | null; +export type PositioningImperativeRef = { + updatePosition: () => void; + setTarget: (target: HTMLElement | PositioningVirtualElement) => void; +}; + +// @public (undocumented) +export interface PositioningProps extends Omit { + imperativeRef?: React_2.Ref; + target?: HTMLElement | PositioningVirtualElement | null; } // @public (undocumented) @@ -92,19 +90,22 @@ export type PositioningShorthand = PositioningProps | PositioningShorthandValue; // @public (undocumented) export type PositioningShorthandValue = 'above' | 'above-start' | 'above-end' | 'below' | 'below-start' | 'below-end' | 'before' | 'before-top' | 'before-bottom' | 'after' | 'after-top' | 'after-bottom'; +// @public (undocumented) +export type PositioningVirtualElement = VirtualElement; + // @public (undocumented) export function resolvePositioningShorthand(shorthand: PositioningShorthand | undefined | null): Readonly; // @public -export function usePopper(options?: UsePopperOptions): { +export const useMouseTarget: (initialState?: VirtualElement | (() => PositioningVirtualElement) | undefined) => readonly [VirtualElement | undefined, (event: React_2.MouseEvent | MouseEvent | undefined | null) => void]; + +// @public (undocumented) +export function usePositioning(options: UseFloatingUIOptions): { targetRef: React_2.MutableRefObject; containerRef: React_2.MutableRefObject; arrowRef: React_2.MutableRefObject; }; -// @public -export const usePopperMouseTarget: (initialState?: PopperJs.VirtualElement | (() => PopperJs.VirtualElement) | undefined) => readonly [PopperJs.VirtualElement | undefined, (event: React_2.MouseEvent | MouseEvent | undefined | null) => void]; - // (No @packageDocumentation comment for this package) ``` diff --git a/packages/react-positioning/package.json b/packages/react-positioning/package.json index 50d9bf07a1860..9b749bdd34593 100644 --- a/packages/react-positioning/package.json +++ b/packages/react-positioning/package.json @@ -28,11 +28,11 @@ "@fluentui/scripts": "^1.0.0" }, "dependencies": { + "@floating-ui/dom": "^0.4.0", "@griffel/react": "1.0.0", "@fluentui/react-shared-contexts": "9.0.0-rc.4", "@fluentui/react-theme": "9.0.0-rc.4", "@fluentui/react-utilities": "9.0.0-rc.5", - "@popperjs/core": "~2.4.3", "tslib": "^2.1.0" }, "peerDependencies": { diff --git a/packages/react-positioning/src/contants.ts b/packages/react-positioning/src/contants.ts new file mode 100644 index 0000000000000..9766a880b866f --- /dev/null +++ b/packages/react-positioning/src/contants.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-positioning/src/createArrowStyles.ts b/packages/react-positioning/src/createArrowStyles.ts index 865747d2bcc58..0abd8842f04bd 100644 --- a/packages/react-positioning/src/createArrowStyles.ts +++ b/packages/react-positioning/src/createArrowStyles.ts @@ -70,7 +70,6 @@ export function createArrowStyles(options: CreateArrowStylesOptions): GriffelSty position: 'absolute', backgroundColor: 'inherit', visibility: 'hidden', - zIndex: -1, ...(arrowHeight && createArrowHeightStyles(arrowHeight)), diff --git a/packages/react-positioning/src/createVirtualElementFromClick.ts b/packages/react-positioning/src/createVirtualElementFromClick.ts index 5d531eadca6e0..7ba51980b9030 100644 --- a/packages/react-positioning/src/createVirtualElementFromClick.ts +++ b/packages/react-positioning/src/createVirtualElementFromClick.ts @@ -1,17 +1,21 @@ -import type { PopperVirtualElement } from './types'; +import type { ClientRectObject } from '@floating-ui/dom'; +import type { PositioningVirtualElement } from './types'; /** * Creates a virtual element based on the position of a click event * Can be used as a target for popper in scenarios such as context menus */ -export function createVirtualElementFromClick(nativeEvent: MouseEvent): PopperVirtualElement { +export function createVirtualElementFromClick(nativeEvent: MouseEvent): PositioningVirtualElement { const left = nativeEvent.clientX; const top = nativeEvent.clientY; const right = left + 1; const bottom = top + 1; - function getBoundingClientRect(): ClientRect { + function getBoundingClientRect(): ClientRectObject { return { + x: left, + y: top, + left, top, right, diff --git a/packages/react-positioning/src/index.ts b/packages/react-positioning/src/index.ts index 8c3ce17a17c2a..2cdf87195a223 100644 --- a/packages/react-positioning/src/index.ts +++ b/packages/react-positioning/src/index.ts @@ -1,6 +1,6 @@ -export * from './createVirtualElementFromClick'; -export * from './createArrowStyles'; -export * from './usePopper'; -export * from './usePopperMouseTarget'; +export { createVirtualElementFromClick } from './createVirtualElementFromClick'; +export { createArrowStyles, createArrowHeightStyles } from './createArrowStyles'; +export { usePositioning } from './usePositioning'; +export { useMouseTarget } from './useMouseTarget'; export { resolvePositioningShorthand, mergeArrowOffset } from './utils/index'; export * from './types'; diff --git a/packages/react-positioning/src/isIntersectingModifier.ts b/packages/react-positioning/src/isIntersectingModifier.ts deleted file mode 100644 index 9f0fcc0993884..0000000000000 --- a/packages/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-positioning/src/middleware/coverTarget.ts b/packages/react-positioning/src/middleware/coverTarget.ts new file mode 100644 index 0000000000000..bbc9e119ce596 --- /dev/null +++ b/packages/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-positioning/src/middleware/flip.ts b/packages/react-positioning/src/middleware/flip.ts new file mode 100644 index 0000000000000..7a810290005be --- /dev/null +++ b/packages/react-positioning/src/middleware/flip.ts @@ -0,0 +1,18 @@ +import { flip as baseFlip } from '@floating-ui/dom'; +import type { FloatingUIOptions } 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-positioning/src/middleware/index.ts b/packages/react-positioning/src/middleware/index.ts new file mode 100644 index 0000000000000..edd88c2cf6dbe --- /dev/null +++ b/packages/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-positioning/src/middleware/intersecting.ts b/packages/react-positioning/src/middleware/intersecting.ts new file mode 100644 index 0000000000000..b09865f265e86 --- /dev/null +++ b/packages/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-positioning/src/middleware/maxSize.ts b/packages/react-positioning/src/middleware/maxSize.ts new file mode 100644 index 0000000000000..e8b1cc7ed8b68 --- /dev/null +++ b/packages/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 { FloatingUIOptions } from '../types'; +import { parseFloatingUIPlacement } from '../utils/index'; + +export function maxSize(autoSize: FloatingUIOptions['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-positioning/src/middleware/offset.ts b/packages/react-positioning/src/middleware/offset.ts new file mode 100644 index 0000000000000..fde2112d3053f --- /dev/null +++ b/packages/react-positioning/src/middleware/offset.ts @@ -0,0 +1,11 @@ +import { offset as baseOffset } from '@floating-ui/dom'; +import type { FloatingUIOptions } from '../types'; +import { getFloatingUIOffset } from '../utils/getFloatingUIOffset'; + +/** + * Wraps floating UI offset middleware to to transform offset value + */ +export function offset(offsetValue: FloatingUIOptions['offset']) { + const floatingUIOffset = getFloatingUIOffset(offsetValue); + return baseOffset(floatingUIOffset); +} diff --git a/packages/react-positioning/src/middleware/shift.ts b/packages/react-positioning/src/middleware/shift.ts new file mode 100644 index 0000000000000..56019e083ee62 --- /dev/null +++ b/packages/react-positioning/src/middleware/shift.ts @@ -0,0 +1,25 @@ +import { shift as baseShift, limitShift } from '@floating-ui/dom'; +import type { FloatingUIOptions } from '../types'; +import { getBoundary } from '../utils/index'; + +export interface ShiftMiddlewareOptions extends Pick { + hasScrollableElement?: boolean; + disableTether?: FloatingUIOptions['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-positioning/src/types.ts b/packages/react-positioning/src/types.ts index 62143648461b5..53994c9331bb8 100644 --- a/packages/react-positioning/src/types.ts +++ b/packages/react-positioning/src/types.ts @@ -1,26 +1,30 @@ -import * as PopperJs from '@popperjs/core'; import * as React from 'react'; +import { Boundary as FloatingUIBoundary, Rect, VirtualElement } from '@floating-ui/dom'; + export type OffsetFunctionParam = { - popper: PopperJs.Rect; - reference: PopperJs.Rect; - placement: PopperJs.Placement; + positioned: Rect; + target: Rect; + position: Position; + alignment?: Alignment; }; -export type OffsetFunction = (param: OffsetFunctionParam) => [number | null | undefined, number | null | undefined]; +export type OffsetObject = { mainAxis?: number; crossAxis?: number }; + +export type OffsetFunction = (param: OffsetFunctionParam) => OffsetObject | number; -export type Offset = OffsetFunction | [number | null | undefined, number | null | undefined]; +export type Offset = OffsetFunction | OffsetObject | number; export type Position = 'above' | 'below' | 'before' | 'after'; export type Alignment = 'top' | 'bottom' | 'start' | 'end' | 'center'; export type AutoSize = 'height' | 'height-always' | 'width' | 'width-always' | 'always' | boolean; -export type Boundary = PopperJs.Boundary | 'scrollParent' | 'window'; +export type Boundary = FloatingUIBoundary | 'scrollParent' | 'window'; -export type PopperRefHandle = { +export type PositioningImperativeRef = { /** - * Updates the position of the popper imperatively. + * Updates the position imperatively. * Useful when the position of the target changes from other factors than scrolling of window resize. */ updatePosition: () => void; @@ -29,20 +33,20 @@ export type PopperRefHandle = { * Sets the target and updates positioning imperatively. * Useful for avoiding double renders with the target option. */ - setTarget: (target: HTMLElement | PopperVirtualElement) => void; + setTarget: (target: HTMLElement | PositioningVirtualElement) => void; }; -export type PopperVirtualElement = PopperJs.VirtualElement; +export type PositioningVirtualElement = VirtualElement; -export interface PopperOptions { +export interface FloatingUIOptions { /** Alignment for the component. Only has an effect if used with the @see position option */ align?: Alignment; - /** The element which will define the boundaries of the popper position for the flip behavior. */ - flipBoundary?: Boundary; + /** The element which will define the boundaries of the positioned element for the flip behavior. */ + flipBoundary?: Boundary | null; - /** The element which will define the boundaries of the popper position for the overflow behavior. */ - overflowBoundary?: Boundary; + /** The element which will define the boundaries of the positioned element for the overflow behavior. */ + overflowBoundary?: Boundary | null; /** * Position for the component. Position has higher priority than align. If position is vertical ('above' | 'below') @@ -53,13 +57,13 @@ export interface PopperOptions { position?: Position; /** - * Enables the Popper box to position itself in 'fixed' mode (default value is position: 'absolute') + * Enables the position element to be positioned with 'fixed' (default value is position: 'absolute') * @default false */ positionFixed?: boolean; /** - * Lets you displace a popper element from its reference element. + * Lets you displace a positioned element from its reference element. * This can be useful if you need to apply some margin between them or if you need to fine tune the * position according to some custom logic. */ @@ -72,7 +76,7 @@ export interface PopperOptions { arrowPadding?: number; /** - * Applies max-height and max-width on popper to fit it within the available space in viewport. + * Applies max-height and max-width on the positioned element to fit it within the available space in viewport. * true enables this for both width and height when overflow happens. * 'always' applies `max-height`/`max-width` regardless of overflow. * 'height' applies `max-height` when overflow happens, and 'width' for `max-width` @@ -92,7 +96,7 @@ export interface PopperOptions { pinned?: boolean; /** - * When the reference element or the viewport is outside viewport allows a popper element to be fully in viewport. + * When the reference element or the viewport is outside viewport allows a positioned element to be fully in viewport. * "all" enables this behavior for all axis. */ // eslint-disable-next-line @typescript-eslint/naming-convention @@ -101,14 +105,14 @@ export interface PopperOptions { export interface PositioningProps // "positionFixed" & "unstable_disableTether" are not exported as public API (yet) - extends Omit { - /** An imperative handle to Popper methods. */ - popperRef?: React.Ref; + extends Omit { + /** Imperative methods for positioning */ + imperativeRef?: React.Ref; /** - * Manual override for popper target. Useful for scenarios where a component accepts user prop to override target + * Manual override for the target element. Useful for scenarios where a component accepts user prop to override target */ - target?: HTMLElement | PopperVirtualElement | null; + target?: HTMLElement | PositioningVirtualElement | null; } export type PositioningShorthandValue = diff --git a/packages/react-positioning/src/usePopperMouseTarget.ts b/packages/react-positioning/src/useMouseTarget.ts similarity index 76% rename from packages/react-positioning/src/usePopperMouseTarget.ts rename to packages/react-positioning/src/useMouseTarget.ts index 8fd292613484c..ad0f1eeb2e8f2 100644 --- a/packages/react-positioning/src/usePopperMouseTarget.ts +++ b/packages/react-positioning/src/useMouseTarget.ts @@ -1,17 +1,17 @@ import * as React from 'react'; import { createVirtualElementFromClick } from './createVirtualElementFromClick'; -import * as PopperJs from '@popperjs/core'; +import type { PositioningVirtualElement } from './types'; /** * A state hook that manages a popper virtual element from mouseevents. * Useful for scenarios where a component needs to be positioned by mouse click (e.g. contextmenu) * React synthetic events are not persisted by this hook * - * @param initialState - initializes a user provided state similare to useState + * @param initialState - initializes a user provided state similar to useState * @returns state and dispatcher for a Popper virtual element that uses native/synthetic mouse events */ -export const usePopperMouseTarget = (initialState?: PopperJs.VirtualElement | (() => PopperJs.VirtualElement)) => { - const [virtualElement, setVirtualElement] = React.useState(initialState); +export const useMouseTarget = (initialState?: PositioningVirtualElement | (() => PositioningVirtualElement)) => { + const [virtualElement, setVirtualElement] = React.useState(initialState); const setVirtualMouseTarget = (event: React.MouseEvent | MouseEvent | undefined | null) => { if (event === undefined || event === null) { @@ -28,7 +28,7 @@ export const usePopperMouseTarget = (initialState?: PopperJs.VirtualElement | (( if (!(mouseevent instanceof MouseEvent) && process.env.NODE_ENV !== 'production') { // eslint-disable-next-line no-console - console.error('usePopperMouseTarget should only be used with MouseEvent'); + console.error('useMouseTarget should only be used with MouseEvent'); } const contextTarget = createVirtualElementFromClick(mouseevent); diff --git a/packages/react-positioning/src/usePopper.ts b/packages/react-positioning/src/usePopper.ts deleted file mode 100644 index 39b9cb6b6c98b..0000000000000 --- a/packages/react-positioning/src/usePopper.ts +++ /dev/null @@ -1,471 +0,0 @@ -import { useEventCallback, useIsomorphicLayoutEffect, useFirstMount, canUseDOM } from '@fluentui/react-utilities'; -import { useFluent } from '@fluentui/react-shared-contexts'; -import * as PopperJs from '@popperjs/core'; -import * as React from 'react'; - -import { isIntersectingModifier } from './isIntersectingModifier'; -import { - getScrollParent, - applyRtlToOffset, - getPlacement, - getReactFiberFromNode, - getBoundary, - useCallbackRef, - getBasePlacement, -} from './utils/index'; -import type { PopperVirtualElement, PopperOptions, PositioningProps } from './types'; - -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: PopperOptions, 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(offset) : 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 = getBasePlacement(state.placement); - - 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 = getBasePlacement(state.placement); - 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, - ], - ); -} - -/** - * 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 usePopper( - options: UsePopperOptions = {}, -): { - // React refs are supposed to be contravariant - // (allows a more general type to be passed rather than a more specific one) - // However, Typescript currently can't infer that fact for refs - // See https://github.com/microsoft/TypeScript/issues/30748 for more information - // eslint-disable-next-line @typescript-eslint/no-explicit-any - targetRef: React.MutableRefObject; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - containerRef: React.MutableRefObject; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - arrowRef: React.MutableRefObject; -} { - 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 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 (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; - } - - 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); - - React.useImperativeHandle( - options.popperRef, - () => ({ - updatePosition: () => { - popperInstanceRef.current?.update(); - }, - setTarget: (target: HTMLElement | PopperVirtualElement) => { - if (options.target && process.env.NODE_ENV !== 'production') { - const err = new Error(); - // eslint-disable-next-line no-console - console.warn('Imperative setTarget should not be used at the same time as target option'); - // eslint-disable-next-line no-console - console.warn(err.stack); - } - - overrideTargetRef.current = target; - }, - }), - // Missing deps: - // options.target - only used for a runtime warning - // targetRef - Stable between renders - // eslint-disable-next-line react-hooks/exhaustive-deps - [], - ); - - useIsomorphicLayoutEffect(() => { - if (options.target) { - overrideTargetRef.current = options.target; - } - }, [options.target, overrideTargetRef]); - 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], - ); - - if (process.env.NODE_ENV !== 'production') { - // This checked should run only in development mode - // eslint-disable-next-line react-hooks/rules-of-hooks - React.useEffect(() => { - if (containerRef.current) { - const contentNode = containerRef.current; - const treeWalker = contentNode.ownerDocument?.createTreeWalker(contentNode, NodeFilter.SHOW_ELEMENT, { - acceptNode: hasAutofocusFilter, - }); - - while (treeWalker?.nextNode()) { - const node = treeWalker.currentNode; - // eslint-disable-next-line no-console - console.warn(':', node); - // eslint-disable-next-line no-console - console.warn( - [ - ': ^ this node contains "autoFocus" prop on a React element. This can break the initial', - 'positioning of an element and cause a window jump effect. This issue occurs because React polyfills', - '"autoFocus" behavior to solve inconsistencies between different browsers:', - 'https://github.com/facebook/react/issues/11851#issuecomment-351787078', - '\n', - 'However, ".focus()" in this case occurs before any other React effects will be executed', - '(React.useEffect(), componentDidMount(), etc.) and we can not prevent this behavior. If you really', - 'want to use "autoFocus" please add "position: fixed" to styles of the element that is wrapped by', - '"Popper".', - `In general, it's not recommended to use "autoFocus" as it may break accessibility aspects:`, - 'https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/master/docs/rules/no-autofocus.md', - '\n', - 'We suggest to use the "trapFocus" prop on Fluent components or a catch "ref" and then use', - '"ref.current.focus" in React.useEffect():', - 'https://reactjs.org/docs/refs-and-the-dom.html#adding-a-ref-to-a-dom-element', - ].join(' '), - ); - } - } - // We run this check once, no need to add deps here - // TODO: Should be rework to handle options.enabled and contentRef updates - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - } - - return { targetRef, containerRef, arrowRef }; -} diff --git a/packages/react-positioning/src/usePositioning.ts b/packages/react-positioning/src/usePositioning.ts new file mode 100644 index 0000000000000..00d820e61996f --- /dev/null +++ b/packages/react-positioning/src/usePositioning.ts @@ -0,0 +1,339 @@ +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 } from '@fluentui/react-shared-contexts'; +import { canUseDOM, useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; +import { useEventCallback } from '@fluentui/react-utilities'; +import * as React from 'react'; +import type { FloatingUIOptions, PositioningProps, PositioningVirtualElement } from './types'; +import { + useCallbackRef, + toFloatingUIPlacement, + toggleScrollListener, + hasAutofocusFilter, + debounce, + hasScrollParent, +} from './utils/index'; +import { + shift as shiftMiddleware, + flip as flipMiddleware, + coverTarget as coverTargetMiddleware, + maxSize as maxSizeMiddleware, + offset as offsetMiddleware, + intersecting as intersectingMiddleware, +} from './middleware/index'; +import { + DATA_POSITIONING_ESCAPED, + DATA_POSITIONING_INTERSECTING, + DATA_POSITIONING_HIDDEN, + DATA_POSITIONING_PLACEMENT, +} from './contants'; + +export function usePositioning( + options: UseFloatingUIOptions, +): { + // React refs are supposed to be contravariant + // (allows a more general type to be passed rather than a more specific one) + // However, Typescript currently can't infer that fact for refs + // See https://github.com/microsoft/TypeScript/issues/30748 for more information + // eslint-disable-next-line @typescript-eslint/no-explicit-any + targetRef: React.MutableRefObject; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + containerRef: React.MutableRefObject; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + arrowRef: React.MutableRefObject; +} { + const { targetDocument } = useFluent(); + const { enabled = true } = options; + const resolveFloatingUIOptions = useFloatingUIOptions(options); + + const forceUpdate = useEventCallback(() => { + const target = overrideTargetRef.current ?? targetRef.current; + if (!canUseDOM || !enabled || !target || !containerRef.current) { + return; + } + + const { placement, middleware, strategy } = resolveFloatingUIOptions( + 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, + }); + }, + ); + }); + + 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.imperativeRef, + () => ({ + updatePosition, + setTarget: (target: HTMLElement | PositioningVirtualElement) => { + if (options.target && process.env.NODE_ENV !== 'production') { + const err = new Error(); + // eslint-disable-next-line no-console + console.warn('Imperative setTarget should not be used at the same time as target option'); + // eslint-disable-next-line no-console + console.warn(err.stack); + } + + overrideTargetRef.current = target; + }, + }), + // Missing deps: + // options.target - only used for a runtime warning + // overrideTargetRef - Stable between renders + // updatePosition - Stable between renders + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + useIsomorphicLayoutEffect(() => { + if (options.target) { + overrideTargetRef.current = options.target; + } + }, [options.target, overrideTargetRef, containerRef]); + + useIsomorphicLayoutEffect(() => { + updatePosition(); + }, [enabled, resolveFloatingUIOptions, 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 + // eslint-disable-next-line react-hooks/rules-of-hooks + React.useEffect(() => { + if (containerRef.current) { + const contentNode = containerRef.current; + const treeWalker = contentNode.ownerDocument?.createTreeWalker(contentNode, NodeFilter.SHOW_ELEMENT, { + acceptNode: hasAutofocusFilter, + }); + + while (treeWalker.nextNode()) { + const node = treeWalker.currentNode; + // eslint-disable-next-line no-console + console.warn(':', node); + // eslint-disable-next-line no-console + console.warn( + [ + ': ^ this node contains "autoFocus" prop on a React element. This can break the initial', + 'positioning of an element and cause a window jump effect. This issue occurs because React polyfills', + '"autoFocus" behavior to solve inconsistencies between different browsers:', + 'https://github.com/facebook/react/issues/11851#issuecomment-351787078', + '\n', + 'However, ".focus()" in this case occurs before any other React effects will be executed', + '(React.useEffect(), componentDidMount(), etc.) and we can not prevent this behavior. If you really', + 'want to use "autoFocus" please add "position: fixed" to styles of the element that is wrapped by', + '"Popper".', + `In general, it's not recommended to use "autoFocus" as it may break accessibility aspects:`, + 'https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/master/docs/rules/no-autofocus.md', + '\n', + 'We suggest to use the "trapFocus" prop on Fluent components or a catch "ref" and then use', + '"ref.current.focus" in React.useEffect():', + 'https://reactjs.org/docs/refs-and-the-dom.html#adding-a-ref-to-a-dom-element', + ].join(' '), + ); + } + } + // We run this check once, no need to add deps here + // TODO: Should be rework to handle options.enabled and contentRef updates + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + } + + return { targetRef, containerRef, arrowRef }; +} + +interface UseFloatingUIOptions extends PositioningProps { + /** + * If false, does not position anything + */ + enabled?: boolean; +} + +function useFloatingUIOptions(options: FloatingUIOptions) { + 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', top: 0, left: 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-positioning/src/utils/debounce.ts b/packages/react-positioning/src/utils/debounce.ts new file mode 100644 index 0000000000000..e273588b933b1 --- /dev/null +++ b/packages/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-positioning/src/utils/fromFloatingUIPlacement.test.ts b/packages/react-positioning/src/utils/fromFloatingUIPlacement.test.ts new file mode 100644 index 0000000000000..d5dfbb37ec7b0 --- /dev/null +++ b/packages/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-positioning/src/utils/fromFloatingUIPlacement.ts b/packages/react-positioning/src/utils/fromFloatingUIPlacement.ts new file mode 100644 index 0000000000000..15255c5ece1a9 --- /dev/null +++ b/packages/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-positioning/src/utils/getBasePlacement.test.ts b/packages/react-positioning/src/utils/getBasePlacement.test.ts deleted file mode 100644 index 505bf3756ab03..0000000000000 --- a/packages/react-positioning/src/utils/getBasePlacement.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import * as PopperJs from '@popperjs/core'; -import { getBasePlacement } from './getBasePlacement'; - -describe('getBasePlacement', () => { - it.each([ - ['top', 'top'], - ['bottom', 'bottom'], - ['right', 'right'], - ['left', 'left'], - ['top-start', 'top'], - ['top-end', 'top'], - ['bottom-start', 'bottom'], - ['bottom-end', 'bottom'], - ['right-start', 'right'], - ['right-end', 'right'], - ['left-start', 'left'], - ['left-end', 'left'], - ])('should return %s from %s', (placement, basePlacement) => { - expect(getBasePlacement((placement as unknown) as PopperJs.Placement)).toEqual(basePlacement); - }); -}); diff --git a/packages/react-positioning/src/utils/getBasePlacement.ts b/packages/react-positioning/src/utils/getBasePlacement.ts deleted file mode 100644 index 419d9b6fd5ab3..0000000000000 --- a/packages/react-positioning/src/utils/getBasePlacement.ts +++ /dev/null @@ -1,11 +0,0 @@ -import * as PopperJs from '@popperjs/core'; - -/** - * Returns the base placement value - * @param placement - the popper placement (i.e. bottom-start) - * - * @returns bottom-start -> bottom - */ -export function getBasePlacement(placement: PopperJs.Placement): PopperJs.BasePlacement { - return placement.split('-')[0] as PopperJs.BasePlacement; -} diff --git a/packages/react-positioning/src/utils/getBoundary.ts b/packages/react-positioning/src/utils/getBoundary.ts index 5cd0bd9a27af2..f1ac52dc0eaf8 100644 --- a/packages/react-positioning/src/utils/getBoundary.ts +++ b/packages/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,7 +6,7 @@ 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): PopperJs.Boundary | undefined { +export function getBoundary(element: HTMLElement | null, boundary?: Boundary): FloatingUIBoundary | undefined { if (boundary === 'window') { return element?.ownerDocument!.documentElement; } diff --git a/packages/react-positioning/src/utils/getFloatingUIOffset.test.ts b/packages/react-positioning/src/utils/getFloatingUIOffset.test.ts new file mode 100644 index 0000000000000..d708aae21f07a --- /dev/null +++ b/packages/react-positioning/src/utils/getFloatingUIOffset.test.ts @@ -0,0 +1,62 @@ +import { OffsetFunction } from '../types'; +import { FloatingUIOffsetFunction, getFloatingUIOffset } from './getFloatingUIOffset'; + +describe('getFloatingUIOffset', () => { + 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({ 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({ 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 = ({ positioned }) => { + if (positioned === dummyRect) { + return 1; + } + + return -1; + }; + const transformedOffset = getFloatingUIOffset(offsetFn) as FloatingUIOffsetFunction; + expect(transformedOffset({ 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 = ({ target }) => { + if (target === dummyRect) { + return 1; + } + + return -1; + }; + const transformedOffset = getFloatingUIOffset(offsetFn) as FloatingUIOffsetFunction; + expect(transformedOffset({ floating: dummyRect, reference: dummyRect, placement: 'top-start' })).toEqual(1); + }); +}); diff --git a/packages/react-positioning/src/utils/getFloatingUIOffset.ts b/packages/react-positioning/src/utils/getFloatingUIOffset.ts new file mode 100644 index 0000000000000..4851100aa0df6 --- /dev/null +++ b/packages/react-positioning/src/utils/getFloatingUIOffset.ts @@ -0,0 +1,59 @@ +import type { Offset } from '../types'; +import type { Rect, Placement } 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: { + floating: Rect; + reference: Rect; + placement: Placement; +}) => 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 ({ floating, reference, placement }) => { + const { position, alignment } = fromFloatingUIPlacement(placement); + return rawOffset({ positioned: floating, target: reference, position, alignment }); + }; +} diff --git a/packages/react-positioning/src/utils/getScrollParent.ts b/packages/react-positioning/src/utils/getScrollParent.ts index c2392906709c6..eb9af2a63cbfd 100644 --- a/packages/react-positioning/src/utils/getScrollParent.ts +++ b/packages/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-positioning/src/utils/hasAutoFocusFilter.ts b/packages/react-positioning/src/utils/hasAutoFocusFilter.ts new file mode 100644 index 0000000000000..55f4f23e1f858 --- /dev/null +++ b/packages/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-positioning/src/utils/index.ts b/packages/react-positioning/src/utils/index.ts index 403f4a058ad62..f8d4270df5536 100644 --- a/packages/react-positioning/src/utils/index.ts +++ b/packages/react-positioning/src/utils/index.ts @@ -1,8 +1,12 @@ -export * from './getBasePlacement'; +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 './debounce'; +export * from './toggleScrollListener'; +export * from './hasAutoFocusFilter'; diff --git a/packages/react-positioning/src/utils/mergeArrowOffset.test.ts b/packages/react-positioning/src/utils/mergeArrowOffset.test.ts index 8c04e24583595..3d5883219fedf 100644 --- a/packages/react-positioning/src/utils/mergeArrowOffset.test.ts +++ b/packages/react-positioning/src/utils/mergeArrowOffset.test.ts @@ -1,37 +1,63 @@ import { mergeArrowOffset } from './mergeArrowOffset'; -import type { Offset } from '../types'; +import type { OffsetFunction, OffsetObject } from '../types'; describe('mergeArrowOffset', () => { it.each([null, undefined])('should return arrow offset when user offset is %s', userOffset => { - expect(mergeArrowOffset(userOffset, 1)).toEqual([0, 1]); + expect(mergeArrowOffset(userOffset, 1)).toEqual({ mainAxis: 1 }); }); - it.each([ + const cases = [ [ - [0, 0], - [0, 1], + { crossAxis: 0, mainAxis: 0 }, + { crossAxis: 0, mainAxis: 1 }, ], [ - [0, 1], - [0, 2], + { crossAxis: 0, mainAxis: 1 }, + { crossAxis: 0, mainAxis: 2 }, ], [ - [0, undefined], - [0, 1], + { crossAxis: 0, mainAxis: undefined }, + { crossAxis: 0, mainAxis: 1 }, ], [ - [0, null], - [0, 1], + { crossAxis: 0, mainAxis: null }, + { crossAxis: 0, mainAxis: 1 }, ], [ - [undefined, 0], - [undefined, 1], + { crossAxis: undefined, mainAxis: 0 }, + { crossAxis: undefined, mainAxis: 1 }, ], [ - [null, 0], - [null, 1], + { crossAxis: null, mainAxis: 0 }, + { crossAxis: null, mainAxis: 1 }, ], - ])('should return arrow offset when user offset is %s', (userOffset, expectedOffset) => { - expect(mergeArrowOffset((userOffset as unknown) as Offset, 1)).toEqual(expectedOffset); + ] as const; + + it.each(cases)('should return arrow offset when user offset object is %s', (userOffset, expectedOffset) => { + const mergedOffsetObject = mergeArrowOffset(userOffset as OffsetObject, 1) as OffsetObject; + + expect(mergedOffsetObject).toEqual(expectedOffset); + }); + + it.each(cases)('should return arrow offset when user offset function returns %s', (userOffset, expectedOffset) => { + const offsetFn = () => userOffset; + const mergedOffsetFn = mergeArrowOffset(offsetFn as OffsetFunction, 1) as OffsetFunction; + + expect( + mergedOffsetFn({ + positioned: { height: 0, x: 0, y: 0, width: 0 }, + position: 'above', + alignment: 'start', + target: { height: 0, x: 0, y: 0, width: 0 }, + }), + ).toEqual(expectedOffset); + }); + + it.each(cases)('should return arrow offset when user offset number shorthand is %s', (userOffset, expectedOffset) => { + const shorthand = userOffset.mainAxis; + const mergedOffset = mergeArrowOffset(shorthand, 1) as OffsetObject; + + const expectedOffsetWithoutCrossAxis = { mainAxis: expectedOffset.mainAxis }; + expect(mergedOffset).toEqual(expectedOffsetWithoutCrossAxis); }); }); diff --git a/packages/react-positioning/src/utils/mergeArrowOffset.ts b/packages/react-positioning/src/utils/mergeArrowOffset.ts index c72e6a1f6fff6..351b1295b74e4 100644 --- a/packages/react-positioning/src/utils/mergeArrowOffset.ts +++ b/packages/react-positioning/src/utils/mergeArrowOffset.ts @@ -1,4 +1,4 @@ -import type { Offset } from '../types'; +import type { Offset, OffsetObject } from '../types'; /** * Generally when adding an arrow to popper, it's necessary to offset the position of the popper by the @@ -9,33 +9,28 @@ import type { Offset } from '../types'; * @returns User offset augmented with arrow height */ export function mergeArrowOffset(userOffset: Offset | undefined | null, arrowHeight: number): Offset { - let offsetWithArrow = userOffset; - if (!userOffset) { - return [0, arrowHeight]; + if (typeof userOffset === 'number') { + return addArrowOffset(userOffset, arrowHeight); } - if (Array.isArray(offsetWithArrow)) { - setArrowOffset(offsetWithArrow, arrowHeight); - return offsetWithArrow; + if (typeof userOffset === 'object' && userOffset !== null) { + return addArrowOffset(userOffset, arrowHeight); } - if (typeof offsetWithArrow === 'function') { - const userOffsetFn = offsetWithArrow; - offsetWithArrow = offsetParams => { - const offset = userOffsetFn(offsetParams); - setArrowOffset(offset, arrowHeight); - return offset; + if (typeof userOffset === 'function') { + return offsetParams => { + const offset = userOffset(offsetParams); + return addArrowOffset(offset, arrowHeight); }; } - // This should never happen - return [0, 0] as never; + return { mainAxis: arrowHeight }; } -const setArrowOffset = (offset: [number | null | undefined, number | null | undefined], arrowHeight: number) => { - if (offset[1] !== null && offset[1] !== undefined) { - offset[1] += arrowHeight; - } else { - offset[1] = arrowHeight; +const addArrowOffset = (offset: OffsetObject | number, arrowHeight: number): OffsetObject => { + if (typeof offset === 'number') { + return { mainAxis: offset + arrowHeight }; } + + return { ...offset, mainAxis: (offset.mainAxis ?? 0) + arrowHeight }; }; diff --git a/packages/react-positioning/src/utils/parseFloatingUIPlacement.test.ts b/packages/react-positioning/src/utils/parseFloatingUIPlacement.test.ts new file mode 100644 index 0000000000000..6ef033c4af213 --- /dev/null +++ b/packages/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-positioning/src/utils/parseFloatingUIPlacement.ts b/packages/react-positioning/src/utils/parseFloatingUIPlacement.ts new file mode 100644 index 0000000000000..71f93a73df839 --- /dev/null +++ b/packages/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-positioning/src/utils/positioningHelper.test.ts b/packages/react-positioning/src/utils/positioningHelper.test.ts deleted file mode 100644 index f8fa611a4cc5e..0000000000000 --- a/packages/react-positioning/src/utils/positioningHelper.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { getPlacement, applyRtlToOffset } from './positioningHelper'; -import type { Alignment, Position, OffsetFunction, OffsetFunctionParam } from '../types'; - -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: OffsetFunction = () => [10, 10]; - const flippedFn = applyRtlToOffset(offsetFn) as OffsetFunction; - - // Assert - expect(flippedFn(({} as unknown) as OffsetFunctionParam)).toEqual([-10, 10]); - }); -}); diff --git a/packages/react-positioning/src/utils/positioningHelper.ts b/packages/react-positioning/src/utils/positioningHelper.ts deleted file mode 100644 index 19e9a13866ed1..0000000000000 --- a/packages/react-positioning/src/utils/positioningHelper.ts +++ /dev/null @@ -1,57 +0,0 @@ -import * as PopperJs from '@popperjs/core'; -import type { Alignment, Offset, OffsetFunction, OffsetFunctionParam, Position } from '../types'; - -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: Offset | undefined): Offset | undefined => { - if (typeof offset === 'undefined') { - return undefined; - } - - if (Array.isArray(offset)) { - offset[0] = offset[0]! * -1; - - return offset; - } - - return ((param: OffsetFunctionParam) => applyRtlToOffset(offset(param))) as OffsetFunction; -}; diff --git a/packages/react-positioning/src/utils/toFloatingUIPlacement.test.ts b/packages/react-positioning/src/utils/toFloatingUIPlacement.test.ts new file mode 100644 index 0000000000000..43f70bc8333b8 --- /dev/null +++ b/packages/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-positioning/src/utils/toFloatingUIPlacement.ts b/packages/react-positioning/src/utils/toFloatingUIPlacement.ts new file mode 100644 index 0000000000000..a70fc97c82595 --- /dev/null +++ b/packages/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-positioning/src/utils/toggleScrollListener.test.ts b/packages/react-positioning/src/utils/toggleScrollListener.test.ts new file mode 100644 index 0000000000000..286366bae692e --- /dev/null +++ b/packages/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-positioning/src/utils/toggleScrollListener.ts b/packages/react-positioning/src/utils/toggleScrollListener.ts new file mode 100644 index 0000000000000..9b507e7be53c3 --- /dev/null +++ b/packages/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/packages/react-positioning/src/utils/useCallbackRef.test.ts b/packages/react-positioning/src/utils/useCallbackRef.test.ts index ce73950c98090..c50d2c64ca434 100644 --- a/packages/react-positioning/src/utils/useCallbackRef.test.ts +++ b/packages/react-positioning/src/utils/useCallbackRef.test.ts @@ -44,18 +44,4 @@ describe('useCallbackRef', () => { // Assert expect(callback).toHaveBeenCalledTimes(0); }); - - it('should skip initial resolve if `skipInitialResolve` is true', () => { - // Arrange - const callback = jest.fn(); - - // Act - renderHook(() => { - const ref = useCallbackRef(null, callback, true); - ref.current = {}; - }); - - // Assert - expect(callback).toHaveBeenCalledTimes(0); - }); }); diff --git a/packages/react-positioning/src/utils/useCallbackRef.ts b/packages/react-positioning/src/utils/useCallbackRef.ts index bd5e312e3f73c..f0aeb6ec77565 100644 --- a/packages/react-positioning/src/utils/useCallbackRef.ts +++ b/packages/react-positioning/src/utils/useCallbackRef.ts @@ -1,5 +1,4 @@ import * as React from 'react'; -import { useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; /** * Creates a MutableRef with ref change callback. Is useful as React.useRef() doesn't notify you when its content @@ -21,9 +20,7 @@ import { useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; export function useCallbackRef( initialValue: T | null, callback: (newValue: T | null, lastValue: T | null) => void, - skipInitialResolve?: boolean, ): React.MutableRefObject { - const isFirst = React.useRef(true); const [ref] = React.useState(() => ({ // value value: initialValue, @@ -36,24 +33,14 @@ export function useCallbackRef( }, set current(value) { const last = ref.value; - if (last !== value) { ref.value = value; - - if (skipInitialResolve && isFirst.current) { - return; - } - ref.callback(value, last); } }, }, })); - useIsomorphicLayoutEffect(() => { - isFirst.current = false; - }, []); - // update callback ref.callback = callback; diff --git a/packages/react-tooltip/src/components/Tooltip/useTooltip.tsx b/packages/react-tooltip/src/components/Tooltip/useTooltip.tsx index 5154d48df1823..f1a42863115c6 100644 --- a/packages/react-tooltip/src/components/Tooltip/useTooltip.tsx +++ b/packages/react-tooltip/src/components/Tooltip/useTooltip.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { mergeArrowOffset, resolvePositioningShorthand, usePopper } from '@fluentui/react-positioning'; +import { mergeArrowOffset, resolvePositioningShorthand, usePositioning } from '@fluentui/react-positioning'; import { TooltipContext, useFluent } from '@fluentui/react-shared-contexts'; import { applyTriggerPropsToChildren, @@ -15,6 +15,7 @@ import { } from '@fluentui/react-utilities'; import type { TooltipProps, TooltipState, TooltipTriggerProps } from './Tooltip.types'; import { arrowHeight, tooltipBorderRadius } from './private/constants'; +import { Offset } from '@fluentui/react-positioning'; /** * Create the state required to render Tooltip. @@ -86,7 +87,7 @@ export const useTooltip_unstable = (props: TooltipProps): TooltipState => { arrowPadding: 2 * tooltipBorderRadius, position: 'above' as const, align: 'center' as const, - offset: [0, 4] as [number, number], + offset: { crossAxis: 0, mainAxis: 4 } as Offset, ...resolvePositioningShorthand(state.positioning), }; @@ -102,7 +103,7 @@ export const useTooltip_unstable = (props: TooltipProps): TooltipState => { targetRef: React.MutableRefObject; containerRef: React.MutableRefObject; arrowRef: React.MutableRefObject; - } = usePopper(popperOptions); + } = usePositioning(popperOptions); state.content.ref = useMergedRefs(state.content.ref, containerRef); state.arrowRef = arrowRef; diff --git a/yarn.lock b/yarn.lock index 74a9dcd3f134c..1650be055e82b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1673,6 +1673,18 @@ unique-filename "^1.1.1" which "^1.3.1" +"@floating-ui/core@^0.6.0": + version "0.6.0" + resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-0.6.0.tgz#bbefe8eaeaa0608509eb863189e37eef930c4204" + integrity sha512-chJj27Tj4q6EUgRaR5m5Va+h+N5yFMP/s3CagVMS9Ug3482jsjZMvS2kuwQQAdLV+/1zCf5uZiN1mksaLerQoA== + +"@floating-ui/dom@^0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-0.4.0.tgz#9f81632b7c0cc7ae3a12b2b9990de14752083a36" + integrity sha512-sfbZCY30r2u9f6h2wBtFtvhlSLP2nUuwsj7feTvZLtxhbrVd+/43jrW1d2OHWfeqx8oUTbCQdO5Z7td9DffOkg== + dependencies: + "@floating-ui/core" "^0.6.0" + "@fluentui/dom-utilities@^1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@fluentui/dom-utilities/-/dom-utilities-1.1.1.tgz#b0bbab665fe726f245800bb9e7883b1ceb54248b"