diff --git a/docs/reference/generated/menu-positioner.json b/docs/reference/generated/menu-positioner.json index dc8fc9f0ac..4c73399556 100644 --- a/docs/reference/generated/menu-positioner.json +++ b/docs/reference/generated/menu-positioner.json @@ -7,7 +7,7 @@ "description": "How to align the popup relative to the specified side." }, "alignOffset": { - "type": "number", + "type": "number | (data) => number", "default": "0", "description": "Additional offset along the alignment axis of the element." }, @@ -16,9 +16,9 @@ "description": "Which side of the anchor element to align the popup against.\nMay automatically change to avoid collisions." }, "sideOffset": { - "type": "number", + "type": "number | (data) => number", "default": "0", - "description": "Distance between the anchor and the popup." + "description": "Distance between the anchor and the popup in pixels.\nAlso accepts a function that returns a number to read the dimensions of the anchor and popup,\nalong with its side and alignment.\n\n- `data.anchor`: the dimensions of the anchor element with properties `width` and `height`.\n- `data.popup`: the dimensions of the popup element with properties `width` and `height`.\n- `data.side`: which side of the anchor element the popup is aligned against.\n- `data.align`: how the popup is aligned relative to the specified side." }, "arrowPadding": { "type": "number", diff --git a/docs/reference/generated/popover-positioner.json b/docs/reference/generated/popover-positioner.json index 9a155f18dd..70dfd6655b 100644 --- a/docs/reference/generated/popover-positioner.json +++ b/docs/reference/generated/popover-positioner.json @@ -8,7 +8,7 @@ "description": "How to align the popup relative to the specified side." }, "alignOffset": { - "type": "number", + "type": "number | (data) => number", "default": "0", "description": "Additional offset along the alignment axis of the element." }, @@ -18,9 +18,9 @@ "description": "Which side of the anchor element to align the popup against.\nMay automatically change to avoid collisions." }, "sideOffset": { - "type": "number", + "type": "number | (data) => number", "default": "0", - "description": "Distance between the anchor and the popup." + "description": "Distance between the anchor and the popup in pixels.\nAlso accepts a function that returns a number to read the dimensions of the anchor and popup,\nalong with its side and alignment.\n\n- `data.anchor`: the dimensions of the anchor element with properties `width` and `height`.\n- `data.popup`: the dimensions of the popup element with properties `width` and `height`.\n- `data.side`: which side of the anchor element the popup is aligned against.\n- `data.align`: how the popup is aligned relative to the specified side." }, "arrowPadding": { "type": "number", diff --git a/docs/reference/generated/preview-card-positioner.json b/docs/reference/generated/preview-card-positioner.json index 5fea11a83e..84d0f57265 100644 --- a/docs/reference/generated/preview-card-positioner.json +++ b/docs/reference/generated/preview-card-positioner.json @@ -8,7 +8,7 @@ "description": "How to align the popup relative to the specified side." }, "alignOffset": { - "type": "number", + "type": "number | (data) => number", "default": "0", "description": "Additional offset along the alignment axis of the element." }, @@ -18,9 +18,9 @@ "description": "Which side of the anchor element to align the popup against.\nMay automatically change to avoid collisions." }, "sideOffset": { - "type": "number", + "type": "number | (data) => number", "default": "0", - "description": "Distance between the anchor and the popup." + "description": "Distance between the anchor and the popup in pixels.\nAlso accepts a function that returns a number to read the dimensions of the anchor and popup,\nalong with its side and alignment.\n\n- `data.anchor`: the dimensions of the anchor element with properties `width` and `height`.\n- `data.popup`: the dimensions of the popup element with properties `width` and `height`.\n- `data.side`: which side of the anchor element the popup is aligned against.\n- `data.align`: how the popup is aligned relative to the specified side." }, "arrowPadding": { "type": "number", diff --git a/docs/reference/generated/select-positioner.json b/docs/reference/generated/select-positioner.json index 97c2deae61..340d72b5e3 100644 --- a/docs/reference/generated/select-positioner.json +++ b/docs/reference/generated/select-positioner.json @@ -8,7 +8,7 @@ "description": "How to align the popup relative to the specified side." }, "alignOffset": { - "type": "number", + "type": "number | (data) => number", "default": "0", "description": "Additional offset along the alignment axis of the element." }, @@ -18,9 +18,9 @@ "description": "Which side of the anchor element to align the popup against.\nMay automatically change to avoid collisions." }, "sideOffset": { - "type": "number", + "type": "number | (data) => number", "default": "0", - "description": "Distance between the anchor and the popup." + "description": "Distance between the anchor and the popup in pixels.\nAlso accepts a function that returns a number to read the dimensions of the anchor and popup,\nalong with its side and alignment.\n\n- `data.anchor`: the dimensions of the anchor element with properties `width` and `height`.\n- `data.popup`: the dimensions of the popup element with properties `width` and `height`.\n- `data.side`: which side of the anchor element the popup is aligned against.\n- `data.align`: how the popup is aligned relative to the specified side." }, "arrowPadding": { "type": "number", diff --git a/docs/reference/generated/tooltip-positioner.json b/docs/reference/generated/tooltip-positioner.json index 538059bb6a..4a1ae35f41 100644 --- a/docs/reference/generated/tooltip-positioner.json +++ b/docs/reference/generated/tooltip-positioner.json @@ -8,7 +8,7 @@ "description": "How to align the popup relative to the specified side." }, "alignOffset": { - "type": "number", + "type": "number | (data) => number", "default": "0", "description": "Additional offset along the alignment axis of the element." }, @@ -18,9 +18,9 @@ "description": "Which side of the anchor element to align the popup against.\nMay automatically change to avoid collisions." }, "sideOffset": { - "type": "number", + "type": "number | (data) => number", "default": "0", - "description": "Distance between the anchor and the popup." + "description": "Distance between the anchor and the popup in pixels.\nAlso accepts a function that returns a number to read the dimensions of the anchor and popup,\nalong with its side and alignment.\n\n- `data.anchor`: the dimensions of the anchor element with properties `width` and `height`.\n- `data.popup`: the dimensions of the popup element with properties `width` and `height`.\n- `data.side`: which side of the anchor element the popup is aligned against.\n- `data.align`: how the popup is aligned relative to the specified side." }, "arrowPadding": { "type": "number", diff --git a/docs/reference/overrides/common.json b/docs/reference/overrides/common.json index cc8641c469..0a31e1a0d8 100644 --- a/docs/reference/overrides/common.json +++ b/docs/reference/overrides/common.json @@ -1,5 +1,8 @@ { "props": { + "alignOffset": { + "type": "number | (data) => number" + }, "anchor": { "type": "React.Ref | Element | VirtualElement | (() => Element | VirtualElement | null) | null" }, @@ -26,6 +29,9 @@ }, "render": { "type": "React.ReactElement | (props, state) => React.ReactElement" + }, + "sideOffset": { + "type": "number | (data) => number" } }, "types": { diff --git a/packages/react/src/menu/positioner/MenuPositioner.test.tsx b/packages/react/src/menu/positioner/MenuPositioner.test.tsx index 934fb73d2f..9da7b940ed 100644 --- a/packages/react/src/menu/positioner/MenuPositioner.test.tsx +++ b/packages/react/src/menu/positioner/MenuPositioner.test.tsx @@ -2,11 +2,13 @@ import * as React from 'react'; import { expect } from 'chai'; import { FloatingRootContext, FloatingTree } from '@floating-ui/react'; import userEvent from '@testing-library/user-event'; -import { flushMicrotasks } from '@mui/internal-test-utils'; +import { describeSkipIf, flushMicrotasks, screen } from '@mui/internal-test-utils'; import { Menu } from '@base-ui-components/react/menu'; import { describeConformance, createRenderer } from '#test-utils'; import { MenuRootContext } from '../root/MenuRootContext'; +const isJSDOM = /jsdom/.test(window.navigator.userAgent); + const testRootContext: MenuRootContext = { floatingRootContext: undefined as unknown as FloatingRootContext, getPopupProps: (p) => ({ ...p }), @@ -31,6 +33,13 @@ const testRootContext: MenuRootContext = { allowMouseUpTriggerRef: { current: false }, }; +const Trigger = React.forwardRef(function Trigger( + props: Menu.Trigger.Props, + ref: React.ForwardedRef, +) { + return } />; +}); + describe('', () => { const { render } = createRenderer(); @@ -289,4 +298,236 @@ describe('', () => { expect(queryByRole('menu', { hidden: true })).to.equal(null); }); }); + + const baselineX = 10; + const baselineY = 36; + const popupWidth = 52; + const popupHeight = 24; + const anchorWidth = 72; + const anchorHeight = 36; + const triggerStyle = { width: anchorWidth, height: anchorHeight }; + const popupStyle = { width: popupWidth, height: popupHeight }; + + describeSkipIf(isJSDOM)('prop: sideOffset', () => { + it('offsets the side when a number is specified', async () => { + const sideOffset = 7; + await render( + + Trigger + + + Popup + + + , + ); + + expect(screen.getByTestId('positioner').style.transform).to.equal( + `translate(${baselineX}px, ${baselineY + sideOffset}px)`, + ); + }); + + it('offsets the side when a function is specified', async () => { + await render( + + Trigger + + data.popup.width + data.anchor.width} + > + Popup + + + , + ); + + expect(screen.getByTestId('positioner').style.transform).to.equal( + `translate(${baselineX}px, ${baselineY + popupWidth + anchorWidth}px)`, + ); + }); + + it('can read the latest side inside sideOffset', async () => { + let side = 'none'; + await render( + + Trigger + + { + side = data.side; + return 0; + }} + > + Popup + + + , + ); + + // correctly flips the side in the browser + expect(side).to.equal('right'); + }); + + it('can read the latest align inside sideOffset', async () => { + let align = 'none'; + await render( + + Trigger + + { + align = data.align; + return 0; + }} + > + Popup + + + , + ); + + // correctly flips the align in the browser + expect(align).to.equal('end'); + }); + + it('reads logical side inside sideOffset', async () => { + let side = 'none'; + await render( + + Trigger + + { + side = data.side; + return 0; + }} + > + Popup + + + , + ); + + // correctly flips the side in the browser + expect(side).to.equal('inline-end'); + }); + }); + + describeSkipIf(isJSDOM)('prop: alignOffset', () => { + it('offsets the align when a number is specified', async () => { + const alignOffset = 7; + await render( + + Trigger + + + Popup + + + , + ); + + expect(screen.getByTestId('positioner').style.transform).to.equal( + `translate(${baselineX + alignOffset}px, ${baselineY}px)`, + ); + }); + + it('offsets the align when a function is specified', async () => { + await render( + + Trigger + + data.popup.width}> + Popup + + + , + ); + + expect(screen.getByTestId('positioner').style.transform).to.equal( + `translate(${baselineX + popupWidth}px, ${baselineY}px)`, + ); + }); + + it('can read the latest side inside alignOffset', async () => { + let side = 'none'; + await render( + + Trigger + + { + side = data.side; + return 0; + }} + > + Popup + + + , + ); + + // correctly flips the side in the browser + expect(side).to.equal('right'); + }); + + it('can read the latest align inside alignOffset', async () => { + let align = 'none'; + await render( + + Trigger + + { + align = data.align; + return 0; + }} + > + Popup + + + , + ); + + // correctly flips the align in the browser + expect(align).to.equal('end'); + }); + + it('reads logical side inside alignOffset', async () => { + let side = 'none'; + await render( + + Trigger + + { + side = data.side; + return 0; + }} + > + Popup + + + , + ); + + // correctly flips the side in the browser + expect(side).to.equal('inline-end'); + }); + }); }); diff --git a/packages/react/src/menu/positioner/MenuPositioner.tsx b/packages/react/src/menu/positioner/MenuPositioner.tsx index d2ef2f12f3..5d46e83218 100644 --- a/packages/react/src/menu/positioner/MenuPositioner.tsx +++ b/packages/react/src/menu/positioner/MenuPositioner.tsx @@ -181,7 +181,7 @@ MenuPositioner.propTypes /* remove-proptypes */ = { * Additional offset along the alignment axis of the element. * @default 0 */ - alignOffset: PropTypes.number, + alignOffset: PropTypes.oneOfType([PropTypes.func, PropTypes.number]), /** * An element to position the popup against. * By default, the popup will be positioned against the trigger. @@ -258,10 +258,17 @@ MenuPositioner.propTypes /* remove-proptypes */ = { */ side: PropTypes.oneOf(['bottom', 'inline-end', 'inline-start', 'left', 'right', 'top']), /** - * Distance between the anchor and the popup. + * Distance between the anchor and the popup in pixels. + * Also accepts a function that returns a number to read the dimensions of the anchor and popup, + * along with its side and alignment. + * + * - `data.anchor`: the dimensions of the anchor element with properties `width` and `height`. + * - `data.popup`: the dimensions of the popup element with properties `width` and `height`. + * - `data.side`: which side of the anchor element the popup is aligned against. + * - `data.align`: how the popup is aligned relative to the specified side. * @default 0 */ - sideOffset: PropTypes.number, + sideOffset: PropTypes.oneOfType([PropTypes.func, PropTypes.number]), /** * Whether to maintain the menu in the viewport after * the anchor element is scrolled out of view. diff --git a/packages/react/src/menu/positioner/useMenuPositioner.ts b/packages/react/src/menu/positioner/useMenuPositioner.ts index 7823fb8479..7f0d6feefa 100644 --- a/packages/react/src/menu/positioner/useMenuPositioner.ts +++ b/packages/react/src/menu/positioner/useMenuPositioner.ts @@ -8,7 +8,12 @@ import type { FloatingEvents, } from '@floating-ui/react'; import { mergeReactProps } from '../../utils/mergeReactProps'; -import { type Boundary, type Side, useAnchorPositioning } from '../../utils/useAnchorPositioning'; +import { + useAnchorPositioning, + type Boundary, + type OffsetFunction, + type Side, +} from '../../utils/useAnchorPositioning'; import type { GenericHTMLProps } from '../../utils/types'; import { useMenuRootContext } from '../root/MenuRootContext'; @@ -121,10 +126,17 @@ export namespace useMenuPositioner { */ side?: Side; /** - * Distance between the anchor and the popup. + * Distance between the anchor and the popup in pixels. + * Also accepts a function that returns a number to read the dimensions of the anchor and popup, + * along with its side and alignment. + * + * - `data.anchor`: the dimensions of the anchor element with properties `width` and `height`. + * - `data.popup`: the dimensions of the popup element with properties `width` and `height`. + * - `data.side`: which side of the anchor element the popup is aligned against. + * - `data.align`: how the popup is aligned relative to the specified side. * @default 0 */ - sideOffset?: number; + sideOffset?: number | OffsetFunction; /** * How to align the popup relative to the specified side. */ @@ -133,7 +145,7 @@ export namespace useMenuPositioner { * Additional offset along the alignment axis of the element. * @default 0 */ - alignOffset?: number; + alignOffset?: number | OffsetFunction; /** * An element or a rectangle that delimits the area that the popup is confined to. * @default 'clipping-ancestors' diff --git a/packages/react/src/popover/positioner/PopoverPositioner.test.tsx b/packages/react/src/popover/positioner/PopoverPositioner.test.tsx index 7a6c547ef8..822a841aa3 100644 --- a/packages/react/src/popover/positioner/PopoverPositioner.test.tsx +++ b/packages/react/src/popover/positioner/PopoverPositioner.test.tsx @@ -1,8 +1,17 @@ import * as React from 'react'; import { Popover } from '@base-ui-components/react/popover'; -import { createRenderer, describeConformance } from '#test-utils'; -import { screen } from '@mui/internal-test-utils'; +import { describeSkipIf, screen } from '@mui/internal-test-utils'; import { expect } from 'chai'; +import { createRenderer, describeConformance } from '#test-utils'; + +const isJSDOM = /jsdom/.test(window.navigator.userAgent); + +const Trigger = React.forwardRef(function Trigger( + props: Popover.Trigger.Props, + ref: React.ForwardedRef, +) { + return } />; +}); describe('', () => { const { render } = createRenderer(); @@ -14,25 +23,235 @@ describe('', () => { }, })); - describe('prop: keepMounted', () => { - it('has hidden attribute when closed', async () => { + const baselineX = 10; + const baselineY = 36; + const popupWidth = 52; + const popupHeight = 24; + const anchorWidth = 72; + const anchorHeight = 36; + const triggerStyle = { width: anchorWidth, height: anchorHeight }; + const popupStyle = { width: popupWidth, height: popupHeight }; + + describeSkipIf(isJSDOM)('prop: sideOffset', () => { + it('offsets the side when a number is specified', async () => { + const sideOffset = 7; + await render( + + Trigger + + + Popup + + + , + ); + + expect(screen.getByTestId('positioner').style.transform).to.equal( + `translate(${baselineX}px, ${baselineY + sideOffset}px)`, + ); + }); + + it('offsets the side when a function is specified', async () => { + await render( + + Trigger + + data.popup.width + data.anchor.width} + > + Popup + + + , + ); + + expect(screen.getByTestId('positioner').style.transform).to.equal( + `translate(${baselineX}px, ${baselineY + popupWidth + anchorWidth}px)`, + ); + }); + + it('can read the latest side inside sideOffset', async () => { + let side = 'none'; + await render( + + Trigger + + { + side = data.side; + return 0; + }} + > + Popup + + + , + ); + + // correctly flips the side in the browser + expect(side).to.equal('right'); + }); + + it('can read the latest align inside sideOffset', async () => { + let align = 'none'; + await render( + + Trigger + + { + align = data.align; + return 0; + }} + > + Popup + + + , + ); + + // correctly flips the align in the browser + expect(align).to.equal('end'); + }); + + it('reads logical side inside sideOffset', async () => { + let side = 'none'; + await render( + + Trigger + + { + side = data.side; + return 0; + }} + > + Popup + + + , + ); + + // correctly flips the side in the browser + expect(side).to.equal('inline-end'); + }); + }); + + describeSkipIf(isJSDOM)('prop: alignOffset', () => { + it('offsets the align when a number is specified', async () => { + const alignOffset = 7; + await render( + + Trigger + + + Popup + + + , + ); + + expect(screen.getByTestId('positioner').style.transform).to.equal( + `translate(${baselineX + alignOffset}px, ${baselineY}px)`, + ); + }); + + it('offsets the align when a function is specified', async () => { + await render( + + Trigger + + data.popup.width}> + Popup + + + , + ); + + expect(screen.getByTestId('positioner').style.transform).to.equal( + `translate(${baselineX + popupWidth}px, ${baselineY}px)`, + ); + }); + + it('can read the latest side inside alignOffset', async () => { + let side = 'none'; await render( - - + + Trigger + + { + side = data.side; + return 0; + }} + > + Popup + + + , + ); + + // correctly flips the side in the browser + expect(side).to.equal('right'); + }); + + it('can read the latest align inside alignOffset', async () => { + let align = 'none'; + await render( + + Trigger + + { + align = data.align; + return 0; + }} + > + Popup + + , ); - expect(screen.getByTestId('positioner')).to.have.attribute('hidden'); + // correctly flips the align in the browser + expect(align).to.equal('end'); }); - it('does not have inert attribute when open', async () => { + it('reads logical side inside alignOffset', async () => { + let side = 'none'; await render( - + Trigger + + { + side = data.side; + return 0; + }} + > + Popup + + , ); - expect(screen.getByTestId('positioner')).not.to.have.attribute('inert'); + // correctly flips the side in the browser + expect(side).to.equal('inline-end'); }); }); }); diff --git a/packages/react/src/popover/positioner/PopoverPositioner.tsx b/packages/react/src/popover/positioner/PopoverPositioner.tsx index 0b8ded616d..5bacb50751 100644 --- a/packages/react/src/popover/positioner/PopoverPositioner.tsx +++ b/packages/react/src/popover/positioner/PopoverPositioner.tsx @@ -124,7 +124,7 @@ PopoverPositioner.propTypes /* remove-proptypes */ = { * Additional offset along the alignment axis of the element. * @default 0 */ - alignOffset: PropTypes.number, + alignOffset: PropTypes.oneOfType([PropTypes.func, PropTypes.number]), /** * An element to position the popup against. * By default, the popup will be positioned against the trigger. @@ -202,10 +202,17 @@ PopoverPositioner.propTypes /* remove-proptypes */ = { */ side: PropTypes.oneOf(['bottom', 'inline-end', 'inline-start', 'left', 'right', 'top']), /** - * Distance between the anchor and the popup. + * Distance between the anchor and the popup in pixels. + * Also accepts a function that returns a number to read the dimensions of the anchor and popup, + * along with its side and alignment. + * + * - `data.anchor`: the dimensions of the anchor element with properties `width` and `height`. + * - `data.popup`: the dimensions of the popup element with properties `width` and `height`. + * - `data.side`: which side of the anchor element the popup is aligned against. + * - `data.align`: how the popup is aligned relative to the specified side. * @default 0 */ - sideOffset: PropTypes.number, + sideOffset: PropTypes.oneOfType([PropTypes.func, PropTypes.number]), /** * Whether to maintain the popup in the viewport after * the anchor element is scrolled out of view. diff --git a/packages/react/src/popover/positioner/usePopoverPositioner.tsx b/packages/react/src/popover/positioner/usePopoverPositioner.tsx index 6d9456b5ec..05a4cc83dd 100644 --- a/packages/react/src/popover/positioner/usePopoverPositioner.tsx +++ b/packages/react/src/popover/positioner/usePopoverPositioner.tsx @@ -6,7 +6,12 @@ import type { FloatingRootContext, } from '@floating-ui/react'; import { mergeReactProps } from '../../utils/mergeReactProps'; -import { type Boundary, type Side, useAnchorPositioning } from '../../utils/useAnchorPositioning'; +import { + useAnchorPositioning, + type Boundary, + type OffsetFunction, + type Side, +} from '../../utils/useAnchorPositioning'; import type { GenericHTMLProps } from '../../utils/types'; import { InteractionType } from '../../utils/useEnhancedClickHandler'; @@ -95,10 +100,17 @@ export namespace usePopoverPositioner { */ side?: Side; /** - * Distance between the anchor and the popup. + * Distance between the anchor and the popup in pixels. + * Also accepts a function that returns a number to read the dimensions of the anchor and popup, + * along with its side and alignment. + * + * - `data.anchor`: the dimensions of the anchor element with properties `width` and `height`. + * - `data.popup`: the dimensions of the popup element with properties `width` and `height`. + * - `data.side`: which side of the anchor element the popup is aligned against. + * - `data.align`: how the popup is aligned relative to the specified side. * @default 0 */ - sideOffset?: number; + sideOffset?: number | OffsetFunction; /** * How to align the popup relative to the specified side. * @default 'center' @@ -108,7 +120,7 @@ export namespace usePopoverPositioner { * Additional offset along the alignment axis of the element. * @default 0 */ - alignOffset?: number; + alignOffset?: number | OffsetFunction; /** * An element or a rectangle that delimits the area that the popup is confined to. * @default 'clipping-ancestors' diff --git a/packages/react/src/preview-card/positioner/PreviewCardPositioner.test.tsx b/packages/react/src/preview-card/positioner/PreviewCardPositioner.test.tsx index 22838875ed..032f5368d7 100644 --- a/packages/react/src/preview-card/positioner/PreviewCardPositioner.test.tsx +++ b/packages/react/src/preview-card/positioner/PreviewCardPositioner.test.tsx @@ -1,7 +1,18 @@ import * as React from 'react'; import { PreviewCard } from '@base-ui-components/react/preview-card'; +import { describeSkipIf, screen } from '@mui/internal-test-utils'; +import { expect } from 'chai'; import { createRenderer, describeConformance } from '#test-utils'; +const isJSDOM = /jsdom/.test(window.navigator.userAgent); + +const Trigger = React.forwardRef(function Trigger( + props: PreviewCard.Trigger.Props, + ref: React.ForwardedRef, +) { + return } />; +}); + describe('', () => { const { render } = createRenderer(); @@ -11,4 +22,239 @@ describe('', () => { return render({node}); }, })); + + const baselineX = 10; + const baselineY = 36; + const popupWidth = 52; + const popupHeight = 24; + const anchorWidth = 72; + const anchorHeight = 36; + const triggerStyle = { width: anchorWidth, height: anchorHeight }; + const popupStyle = { width: popupWidth, height: popupHeight }; + + describeSkipIf(isJSDOM)('prop: sideOffset', () => { + it('offsets the side when a number is specified', async () => { + const sideOffset = 7; + await render( + + Trigger + + + Popup + + + , + ); + + expect(screen.getByTestId('positioner').style.transform).to.equal( + `translate(${baselineX}px, ${baselineY + sideOffset}px)`, + ); + }); + + it('offsets the side when a function is specified', async () => { + await render( + + Trigger + + data.popup.width + data.anchor.width} + > + Popup + + + , + ); + + expect(screen.getByTestId('positioner').style.transform).to.equal( + `translate(${baselineX}px, ${baselineY + popupWidth + anchorWidth}px)`, + ); + }); + + it('can read the latest side inside sideOffset', async () => { + let side = 'none'; + await render( + + Trigger + + { + side = data.side; + return 0; + }} + > + Popup + + + , + ); + + // correctly flips the side in the browser + expect(side).to.equal('right'); + }); + + it('can read the latest align inside sideOffset', async () => { + let align = 'none'; + await render( + + Trigger + + { + align = data.align; + return 0; + }} + > + Popup + + + , + ); + + // correctly flips the align in the browser + expect(align).to.equal('end'); + }); + + it('reads logical side inside sideOffset', async () => { + let side = 'none'; + await render( + + Trigger + + { + side = data.side; + return 0; + }} + > + Popup + + + , + ); + + // correctly flips the side in the browser + expect(side).to.equal('inline-end'); + }); + }); + + describeSkipIf(isJSDOM)('prop: alignOffset', () => { + it('offsets the align when a number is specified', async () => { + const alignOffset = 7; + await render( + + Trigger + + + Popup + + + , + ); + + expect(screen.getByTestId('positioner').style.transform).to.equal( + `translate(${baselineX + alignOffset}px, ${baselineY}px)`, + ); + }); + + it('offsets the align when a function is specified', async () => { + await render( + + Trigger + + data.popup.width} + > + Popup + + + , + ); + + expect(screen.getByTestId('positioner').style.transform).to.equal( + `translate(${baselineX + popupWidth}px, ${baselineY}px)`, + ); + }); + + it('can read the latest side inside alignOffset', async () => { + let side = 'none'; + await render( + + Trigger + + { + side = data.side; + return 0; + }} + > + Popup + + + , + ); + + // correctly flips the side in the browser + expect(side).to.equal('right'); + }); + + it('can read the latest align inside alignOffset', async () => { + let align = 'none'; + await render( + + Trigger + + { + align = data.align; + return 0; + }} + > + Popup + + + , + ); + + // correctly flips the align in the browser + expect(align).to.equal('end'); + }); + + it('reads logical side inside alignOffset', async () => { + let side = 'none'; + await render( + + Trigger + + { + side = data.side; + return 0; + }} + > + Popup + + + , + ); + + // correctly flips the side in the browser + expect(side).to.equal('inline-end'); + }); + }); }); diff --git a/packages/react/src/preview-card/positioner/PreviewCardPositioner.tsx b/packages/react/src/preview-card/positioner/PreviewCardPositioner.tsx index 6f69caae0e..3f4001ccbc 100644 --- a/packages/react/src/preview-card/positioner/PreviewCardPositioner.tsx +++ b/packages/react/src/preview-card/positioner/PreviewCardPositioner.tsx @@ -138,7 +138,7 @@ PreviewCardPositioner.propTypes /* remove-proptypes */ = { * Additional offset along the alignment axis of the element. * @default 0 */ - alignOffset: PropTypes.number, + alignOffset: PropTypes.oneOfType([PropTypes.func, PropTypes.number]), /** * An element to position the popup against. * By default, the popup will be positioned against the trigger. @@ -216,10 +216,17 @@ PreviewCardPositioner.propTypes /* remove-proptypes */ = { */ side: PropTypes.oneOf(['bottom', 'inline-end', 'inline-start', 'left', 'right', 'top']), /** - * Distance between the anchor and the popup. + * Distance between the anchor and the popup in pixels. + * Also accepts a function that returns a number to read the dimensions of the anchor and popup, + * along with its side and alignment. + * + * - `data.anchor`: the dimensions of the anchor element with properties `width` and `height`. + * - `data.popup`: the dimensions of the popup element with properties `width` and `height`. + * - `data.side`: which side of the anchor element the popup is aligned against. + * - `data.align`: how the popup is aligned relative to the specified side. * @default 0 */ - sideOffset: PropTypes.number, + sideOffset: PropTypes.oneOfType([PropTypes.func, PropTypes.number]), /** * Whether to maintain the popup in the viewport after * the anchor element is scrolled out of view. diff --git a/packages/react/src/preview-card/positioner/usePreviewCardPositioner.ts b/packages/react/src/preview-card/positioner/usePreviewCardPositioner.ts index 82bc387025..2e9837d4e4 100644 --- a/packages/react/src/preview-card/positioner/usePreviewCardPositioner.ts +++ b/packages/react/src/preview-card/positioner/usePreviewCardPositioner.ts @@ -6,7 +6,12 @@ import type { FloatingRootContext, } from '@floating-ui/react'; import { mergeReactProps } from '../../utils/mergeReactProps'; -import { Boundary, useAnchorPositioning, type Side } from '../../utils/useAnchorPositioning'; +import { + useAnchorPositioning, + type Boundary, + type OffsetFunction, + type Side, +} from '../../utils/useAnchorPositioning'; import type { GenericHTMLProps } from '../../utils/types'; import { usePreviewCardRootContext } from '../root/PreviewCardContext'; @@ -97,10 +102,17 @@ export namespace usePreviewCardPositioner { */ side?: Side; /** - * Distance between the anchor and the popup. + * Distance between the anchor and the popup in pixels. + * Also accepts a function that returns a number to read the dimensions of the anchor and popup, + * along with its side and alignment. + * + * - `data.anchor`: the dimensions of the anchor element with properties `width` and `height`. + * - `data.popup`: the dimensions of the popup element with properties `width` and `height`. + * - `data.side`: which side of the anchor element the popup is aligned against. + * - `data.align`: how the popup is aligned relative to the specified side. * @default 0 */ - sideOffset?: number; + sideOffset?: number | OffsetFunction; /** * How to align the popup relative to the specified side. * @default 'center' @@ -110,7 +122,7 @@ export namespace usePreviewCardPositioner { * Additional offset along the alignment axis of the element. * @default 0 */ - alignOffset?: number; + alignOffset?: number | OffsetFunction; /** * An element or a rectangle that delimits the area that the popup is confined to. * @default 'clipping-ancestors' diff --git a/packages/react/src/select/positioner/SelectPositioner.test.tsx b/packages/react/src/select/positioner/SelectPositioner.test.tsx index ec9ab3fef2..68b06936bd 100644 --- a/packages/react/src/select/positioner/SelectPositioner.test.tsx +++ b/packages/react/src/select/positioner/SelectPositioner.test.tsx @@ -1,7 +1,18 @@ import * as React from 'react'; import { Select } from '@base-ui-components/react/select'; +import { describeSkipIf, screen } from '@mui/internal-test-utils'; +import { expect } from 'chai'; import { createRenderer, describeConformance } from '#test-utils'; +const isJSDOM = /jsdom/.test(window.navigator.userAgent); + +const Trigger = React.forwardRef(function Trigger( + props: Select.Trigger.Props, + ref: React.ForwardedRef, +) { + return } />; +}); + describe('', () => { const { render } = createRenderer(); @@ -11,4 +22,243 @@ describe('', () => { return render({node}); }, })); + + const baselineX = 10; + const baselineY = 36; + const popupWidth = 52; + const popupHeight = 24; + const anchorWidth = 72; + const anchorHeight = 36; + const triggerStyle = { width: anchorWidth, height: anchorHeight }; + const popupStyle = { width: popupWidth, height: popupHeight }; + + describeSkipIf(isJSDOM)('prop: sideOffset', () => { + it('offsets the side when a number is specified', async () => { + const sideOffset = 7; + await render( + + Trigger + + + Popup + + + , + ); + + expect(screen.getByTestId('positioner').style.transform).to.equal( + `translate(${baselineX}px, ${baselineY + sideOffset}px)`, + ); + }); + + it('offsets the side when a function is specified', async () => { + await render( + + Trigger + + data.popup.width + data.anchor.width} + > + Popup + + + , + ); + + expect(screen.getByTestId('positioner').style.transform).to.equal( + `translate(${baselineX}px, ${baselineY + popupWidth + anchorWidth}px)`, + ); + }); + + it('can read the latest side inside sideOffset', async () => { + let side = 'none'; + await render( + + Trigger + + { + side = data.side; + return 0; + }} + > + Popup + + + , + ); + + // correctly flips the side in the browser + expect(side).to.equal('right'); + }); + + it('can read the latest align inside sideOffset', async () => { + let align = 'none'; + await render( + + Trigger + + { + align = data.align; + return 0; + }} + > + Popup + + + , + ); + + // correctly flips the align in the browser + expect(align).to.equal('end'); + }); + + it('reads logical side inside sideOffset', async () => { + let side = 'none'; + await render( + + Trigger + + { + side = data.side; + return 0; + }} + > + Popup + + + , + ); + + // correctly flips the side in the browser + expect(side).to.equal('inline-end'); + }); + }); + + describeSkipIf(isJSDOM)('prop: alignOffset', () => { + it('offsets the align when a number is specified', async () => { + const alignOffset = 7; + await render( + + Trigger + + + Popup + + + , + ); + + expect(screen.getByTestId('positioner').style.transform).to.equal( + `translate(${baselineX + alignOffset}px, ${baselineY}px)`, + ); + }); + + it('offsets the align when a function is specified', async () => { + await render( + + Trigger + + data.popup.width} + > + Popup + + + , + ); + + expect(screen.getByTestId('positioner').style.transform).to.equal( + `translate(${baselineX + popupWidth}px, ${baselineY}px)`, + ); + }); + + it('can read the latest side inside alignOffset', async () => { + let side = 'none'; + await render( + + Trigger + + { + side = data.side; + return 0; + }} + > + Popup + + + , + ); + + // correctly flips the side in the browser + expect(side).to.equal('right'); + }); + + it('can read the latest align inside alignOffset', async () => { + let align = 'none'; + await render( + + Trigger + + { + align = data.align; + return 0; + }} + > + Popup + + + , + ); + + // correctly flips the align in the browser + expect(align).to.equal('end'); + }); + + it('reads logical side inside alignOffset', async () => { + let side = 'none'; + await render( + + Trigger + + { + side = data.side; + return 0; + }} + > + Popup + + + , + ); + + // correctly flips the side in the browser + expect(side).to.equal('inline-end'); + }); + }); }); diff --git a/packages/react/src/select/positioner/SelectPositioner.tsx b/packages/react/src/select/positioner/SelectPositioner.tsx index 4ca9617e4f..897d58ea90 100644 --- a/packages/react/src/select/positioner/SelectPositioner.tsx +++ b/packages/react/src/select/positioner/SelectPositioner.tsx @@ -118,7 +118,7 @@ SelectPositioner.propTypes /* remove-proptypes */ = { * Additional offset along the alignment axis of the element. * @default 0 */ - alignOffset: PropTypes.number, + alignOffset: PropTypes.oneOfType([PropTypes.func, PropTypes.number]), /** * An element to position the popup against. * By default, the popup will be positioned against the trigger. @@ -238,10 +238,17 @@ SelectPositioner.propTypes /* remove-proptypes */ = { */ side: PropTypes.oneOf(['bottom', 'inline-end', 'inline-start', 'left', 'right', 'top']), /** - * Distance between the anchor and the popup. + * Distance between the anchor and the popup in pixels. + * Also accepts a function that returns a number to read the dimensions of the anchor and popup, + * along with its side and alignment. + * + * - `data.anchor`: the dimensions of the anchor element with properties `width` and `height`. + * - `data.popup`: the dimensions of the popup element with properties `width` and `height`. + * - `data.side`: which side of the anchor element the popup is aligned against. + * - `data.align`: how the popup is aligned relative to the specified side. * @default 0 */ - sideOffset: PropTypes.number, + sideOffset: PropTypes.oneOfType([PropTypes.func, PropTypes.number]), /** * Whether to maintain the select menu in the viewport after * the anchor element is scrolled out of view. diff --git a/packages/react/src/select/positioner/useSelectPositioner.ts b/packages/react/src/select/positioner/useSelectPositioner.ts index d672929ac6..a28813e889 100644 --- a/packages/react/src/select/positioner/useSelectPositioner.ts +++ b/packages/react/src/select/positioner/useSelectPositioner.ts @@ -7,7 +7,12 @@ import type { Middleware, } from '@floating-ui/react'; import type { GenericHTMLProps } from '../../utils/types'; -import { type Boundary, type Side, useAnchorPositioning } from '../../utils/useAnchorPositioning'; +import { + useAnchorPositioning, + type Boundary, + type OffsetFunction, + type Side, +} from '../../utils/useAnchorPositioning'; import { mergeReactProps } from '../../utils/mergeReactProps'; import { useSelectRootContext } from '../root/SelectRootContext'; import { useScrollLock } from '../../utils/useScrollLock'; @@ -120,10 +125,17 @@ export namespace useSelectPositioner { */ side?: Side; /** - * Distance between the anchor and the popup. + * Distance between the anchor and the popup in pixels. + * Also accepts a function that returns a number to read the dimensions of the anchor and popup, + * along with its side and alignment. + * + * - `data.anchor`: the dimensions of the anchor element with properties `width` and `height`. + * - `data.popup`: the dimensions of the popup element with properties `width` and `height`. + * - `data.side`: which side of the anchor element the popup is aligned against. + * - `data.align`: how the popup is aligned relative to the specified side. * @default 0 */ - sideOffset?: number; + sideOffset?: number | OffsetFunction; /** * How to align the popup relative to the specified side. * @default 'start' @@ -133,7 +145,7 @@ export namespace useSelectPositioner { * Additional offset along the alignment axis of the element. * @default 0 */ - alignOffset?: number; + alignOffset?: number | OffsetFunction; /** * An element or a rectangle that delimits the area that the popup is confined to. * @default 'clipping-ancestors' diff --git a/packages/react/src/tooltip/positioner/TooltipPositioner.test.tsx b/packages/react/src/tooltip/positioner/TooltipPositioner.test.tsx index 882fed0f25..3aa9d9c50a 100644 --- a/packages/react/src/tooltip/positioner/TooltipPositioner.test.tsx +++ b/packages/react/src/tooltip/positioner/TooltipPositioner.test.tsx @@ -1,7 +1,18 @@ import * as React from 'react'; import { Tooltip } from '@base-ui-components/react/tooltip'; +import { describeSkipIf, screen } from '@mui/internal-test-utils'; +import { expect } from 'chai'; import { createRenderer, describeConformance } from '#test-utils'; +const isJSDOM = /jsdom/.test(window.navigator.userAgent); + +const Trigger = React.forwardRef(function Trigger( + props: Tooltip.Trigger.Props, + ref: React.ForwardedRef, +) { + return } />; +}); + describe('', () => { const { render } = createRenderer(); @@ -11,4 +22,236 @@ describe('', () => { return render({node}); }, })); + + const baselineX = 10; + const baselineY = 36; + const popupWidth = 52; + const popupHeight = 24; + const anchorWidth = 72; + const anchorHeight = 36; + const triggerStyle = { width: anchorWidth, height: anchorHeight }; + const popupStyle = { width: popupWidth, height: popupHeight }; + + describeSkipIf(isJSDOM)('prop: sideOffset', () => { + it('offsets the side when a number is specified', async () => { + const sideOffset = 7; + await render( + + Trigger + + + Popup + + + , + ); + + expect(screen.getByTestId('positioner').style.transform).to.equal( + `translate(${baselineX}px, ${baselineY + sideOffset}px)`, + ); + }); + + it('offsets the side when a function is specified', async () => { + await render( + + Trigger + + data.popup.width + data.anchor.width} + > + Popup + + + , + ); + + expect(screen.getByTestId('positioner').style.transform).to.equal( + `translate(${baselineX}px, ${baselineY + popupWidth + anchorWidth}px)`, + ); + }); + + it('can read the latest side inside sideOffset', async () => { + let side = 'none'; + await render( + + Trigger + + { + side = data.side; + return 0; + }} + > + Popup + + + , + ); + + // correctly flips the side in the browser + expect(side).to.equal('right'); + }); + + it('can read the latest align inside sideOffset', async () => { + let align = 'none'; + await render( + + Trigger + + { + align = data.align; + return 0; + }} + > + Popup + + + , + ); + + // correctly flips the align in the browser + expect(align).to.equal('end'); + }); + + it('reads logical side inside sideOffset', async () => { + let side = 'none'; + await render( + + Trigger + + { + side = data.side; + return 0; + }} + > + Popup + + + , + ); + + // correctly flips the side in the browser + expect(side).to.equal('inline-end'); + }); + }); + + describeSkipIf(isJSDOM)('prop: alignOffset', () => { + it('offsets the align when a number is specified', async () => { + const alignOffset = 7; + await render( + + Trigger + + + Popup + + + , + ); + + expect(screen.getByTestId('positioner').style.transform).to.equal( + `translate(${baselineX + alignOffset}px, ${baselineY}px)`, + ); + }); + + it('offsets the align when a function is specified', async () => { + await render( + + Trigger + + data.popup.width}> + Popup + + + , + ); + + expect(screen.getByTestId('positioner').style.transform).to.equal( + `translate(${baselineX + popupWidth}px, ${baselineY}px)`, + ); + }); + + it('can read the latest side inside alignOffset', async () => { + let side = 'none'; + await render( + + Trigger + + { + side = data.side; + return 0; + }} + > + Popup + + + , + ); + + // correctly flips the side in the browser + expect(side).to.equal('right'); + }); + + it('can read the latest align inside alignOffset', async () => { + let align = 'none'; + await render( + + Trigger + + { + align = data.align; + return 0; + }} + > + Popup + + + , + ); + + // correctly flips the align in the browser + expect(align).to.equal('end'); + }); + + it('reads logical side inside alignOffset', async () => { + let side = 'none'; + await render( + + Trigger + + { + side = data.side; + return 0; + }} + > + Popup + + + , + ); + + // correctly flips the side in the browser + expect(side).to.equal('inline-end'); + }); + }); }); diff --git a/packages/react/src/tooltip/positioner/TooltipPositioner.tsx b/packages/react/src/tooltip/positioner/TooltipPositioner.tsx index 8d9b5e5eb9..4a62d0330e 100644 --- a/packages/react/src/tooltip/positioner/TooltipPositioner.tsx +++ b/packages/react/src/tooltip/positioner/TooltipPositioner.tsx @@ -133,7 +133,7 @@ TooltipPositioner.propTypes /* remove-proptypes */ = { * Additional offset along the alignment axis of the element. * @default 0 */ - alignOffset: PropTypes.number, + alignOffset: PropTypes.oneOfType([PropTypes.func, PropTypes.number]), /** * An element to position the popup against. * By default, the popup will be positioned against the trigger. @@ -211,10 +211,17 @@ TooltipPositioner.propTypes /* remove-proptypes */ = { */ side: PropTypes.oneOf(['bottom', 'inline-end', 'inline-start', 'left', 'right', 'top']), /** - * Distance between the anchor and the popup. + * Distance between the anchor and the popup in pixels. + * Also accepts a function that returns a number to read the dimensions of the anchor and popup, + * along with its side and alignment. + * + * - `data.anchor`: the dimensions of the anchor element with properties `width` and `height`. + * - `data.popup`: the dimensions of the popup element with properties `width` and `height`. + * - `data.side`: which side of the anchor element the popup is aligned against. + * - `data.align`: how the popup is aligned relative to the specified side. * @default 0 */ - sideOffset: PropTypes.number, + sideOffset: PropTypes.oneOfType([PropTypes.func, PropTypes.number]), /** * Whether to maintain the popup in the viewport after * the anchor element was scrolled out of view. diff --git a/packages/react/src/tooltip/positioner/useTooltipPositioner.ts b/packages/react/src/tooltip/positioner/useTooltipPositioner.ts index 4bfa1e1a16..fbf16ac4d5 100644 --- a/packages/react/src/tooltip/positioner/useTooltipPositioner.ts +++ b/packages/react/src/tooltip/positioner/useTooltipPositioner.ts @@ -1,7 +1,12 @@ import * as React from 'react'; import type { Padding, VirtualElement, FloatingRootContext } from '@floating-ui/react'; import { mergeReactProps } from '../../utils/mergeReactProps'; -import { type Boundary, type Side, useAnchorPositioning } from '../../utils/useAnchorPositioning'; +import { + useAnchorPositioning, + type Boundary, + type OffsetFunction, + type Side, +} from '../../utils/useAnchorPositioning'; import type { GenericHTMLProps } from '../../utils/types'; import { useTooltipRootContext } from '../root/TooltipRootContext'; @@ -97,10 +102,17 @@ export namespace useTooltipPositioner { */ side?: Side; /** - * Distance between the anchor and the popup. + * Distance between the anchor and the popup in pixels. + * Also accepts a function that returns a number to read the dimensions of the anchor and popup, + * along with its side and alignment. + * + * - `data.anchor`: the dimensions of the anchor element with properties `width` and `height`. + * - `data.popup`: the dimensions of the popup element with properties `width` and `height`. + * - `data.side`: which side of the anchor element the popup is aligned against. + * - `data.align`: how the popup is aligned relative to the specified side. * @default 0 */ - sideOffset?: number; + sideOffset?: number | OffsetFunction; /** * How to align the popup relative to the specified side. * @default 'center' @@ -110,7 +122,7 @@ export namespace useTooltipPositioner { * Additional offset along the alignment axis of the element. * @default 0 */ - alignOffset?: number; + alignOffset?: number | OffsetFunction; /** * An element or a rectangle that delimits the area that the popup is confined to. * @default 'clipping-ancestors' diff --git a/packages/react/src/utils/useAnchorPositioning.ts b/packages/react/src/utils/useAnchorPositioning.ts index 5fc6b328c6..87d5f8f4d5 100644 --- a/packages/react/src/utils/useAnchorPositioning.ts +++ b/packages/react/src/utils/useAnchorPositioning.ts @@ -21,10 +21,31 @@ import { import { getSide, getAlignment, type Rect } from '@floating-ui/utils'; import { useEnhancedEffect } from './useEnhancedEffect'; import { useDirection } from '../direction-provider/DirectionContext'; +import { useLatestRef } from './useLatestRef'; + +function getLogicalSide(sideParam: Side, renderedSide: PhysicalSide, isRtl: boolean): Side { + const isLogicalSideParam = sideParam === 'inline-start' || sideParam === 'inline-end'; + const logicalRight = isRtl ? 'inline-start' : 'inline-end'; + const logicalLeft = isRtl ? 'inline-end' : 'inline-start'; + return ( + { + top: 'top', + right: isLogicalSideParam ? logicalRight : 'right', + bottom: 'bottom', + left: isLogicalSideParam ? logicalLeft : 'left', + } satisfies Record + )[renderedSide]; +} export type Side = 'top' | 'bottom' | 'left' | 'right' | 'inline-end' | 'inline-start'; export type Align = 'start' | 'center' | 'end'; export type Boundary = 'clipping-ancestors' | Element | Element[] | Rect; +export type OffsetFunction = (data: { + side: Side; + align: Align; + anchor: { width: number; height: number }; + popup: { width: number; height: number }; +}) => number; interface UseAnchorPositioningParameters { anchor?: @@ -35,9 +56,9 @@ interface UseAnchorPositioningParameters { | null; positionMethod?: 'absolute' | 'fixed'; side?: Side; - sideOffset?: number; + sideOffset?: number | OffsetFunction; align?: Align; - alignOffset?: number; + alignOffset?: number | OffsetFunction; fallbackAxisSideDirection?: 'start' | 'end' | 'none'; collisionBoundary?: Boundary; collisionPadding?: Padding; @@ -118,12 +139,39 @@ export function useAnchorPositioning( // presence. const arrowRef = React.useRef(null); + // Keep these reactive if they're not functions + const sideOffsetRef = useLatestRef(sideOffset); + const alignOffsetRef = useLatestRef(alignOffset); + const sideOffsetDep = typeof sideOffset !== 'function' ? sideOffset : 0; + const alignOffsetDep = typeof alignOffset !== 'function' ? alignOffset : 0; + const middleware: UseFloatingOptions['middleware'] = [ - offset({ - mainAxis: sideOffset, - crossAxis: alignOffset, - alignmentAxis: alignOffset, - }), + offset( + ({ rects, placement: currentPlacement }) => { + const data = { + side: getLogicalSide(sideParam, getSide(currentPlacement), isRtl), + align: getAlignment(currentPlacement) || 'center', + anchor: { width: rects.reference.width, height: rects.reference.height }, + popup: { width: rects.floating.width, height: rects.floating.height }, + } as const; + + const sideAxis = + typeof sideOffsetRef.current === 'function' + ? sideOffsetRef.current(data) + : sideOffsetRef.current; + const alignAxis = + typeof alignOffsetRef.current === 'function' + ? alignOffsetRef.current(data) + : alignOffsetRef.current; + + return { + mainAxis: sideAxis, + crossAxis: alignAxis, + alignmentAxis: alignAxis, + }; + }, + [sideOffsetDep, alignOffsetDep, isRtl, sideParam], + ), ]; const flipMiddleware = flip({ @@ -285,17 +333,7 @@ export function useAnchorPositioning( }, [keepMounted, mounted, elements, update, autoUpdateOptions]); const renderedSide = getSide(renderedPlacement); - const isLogicalSideParam = sideParam === 'inline-start' || sideParam === 'inline-end'; - const logicalRight = isRtl ? 'inline-start' : 'inline-end'; - const logicalLeft = isRtl ? 'inline-end' : 'inline-start'; - const logicalRenderedSide = ( - { - top: 'top', - right: isLogicalSideParam ? logicalRight : 'right', - bottom: 'bottom', - left: isLogicalSideParam ? logicalLeft : 'left', - } satisfies Record - )[renderedSide]; + const logicalRenderedSide = getLogicalSide(sideParam, renderedSide, isRtl); const renderedAlign = getAlignment(renderedPlacement) || 'center'; const anchorHidden = Boolean(middlewareData.hide?.referenceHidden);