diff --git a/change/@fluentui-react-headless-components-preview-fa7a6438-e4cf-4a58-9c0b-5d4d44798e1e.json b/change/@fluentui-react-headless-components-preview-fa7a6438-e4cf-4a58-9c0b-5d4d44798e1e.json new file mode 100644 index 00000000000000..3a03d46e567b1e --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-fa7a6438-e4cf-4a58-9c0b-5d4d44798e1e.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "fix(positioning): fix positioning types and performance issues", + "packageName": "@fluentui/react-headless-components-preview", + "email": "vgenaev@gmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-headless-components-preview/library/etc/positioning.api.md b/packages/react-components/react-headless-components-preview/library/etc/positioning.api.md index 0fa49ee74b6813..07d315e12e20a9 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/positioning.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/positioning.api.md @@ -7,7 +7,7 @@ import { Alignment } from '@fluentui/react-positioning'; import { Position } from '@fluentui/react-positioning'; import { PositioningImperativeRef } from '@fluentui/react-positioning'; -import { PositioningProps } from '@fluentui/react-positioning'; +import type { PositioningProps as PositioningProps_2 } from '@fluentui/react-positioning'; import { PositioningShorthand } from '@fluentui/react-positioning'; import { PositioningShorthandValue } from '@fluentui/react-positioning'; import type * as React_2 from 'react'; @@ -23,13 +23,14 @@ export const ALIGNMENTS: { }; // @public -export function getPlacementString(position: Position, align: LogicalAlignment): string; +export function getPlacementString(position: Position, align: Alignment): PositioningShorthandValue; export { Position } export { PositioningImperativeRef } -export { PositioningProps } +// @public (undocumented) +export type PositioningProps = Pick; // @public (undocumented) export type PositioningReturn = { diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.cy.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.cy.tsx index 290c8700de2c85..1216cacf862141 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.cy.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.cy.tsx @@ -5,6 +5,7 @@ import { PopoverTrigger } from './PopoverTrigger/PopoverTrigger'; import { PopoverSurface } from './PopoverSurface/PopoverSurface'; import type { PopoverProps } from './Popover.types'; import type { JSXElement } from '@fluentui/react-utilities'; +import type { PositioningImperativeRef } from '@fluentui/react-positioning'; const mount = (element: JSXElement) => { mountBase(element); @@ -379,3 +380,37 @@ describe('Popover', () => { }); }); }); + +describe('positioning observer', () => { + const surfaceSelector = popoverContentSelector; + + it('imperative updatePosition() is callable while the surface is open and does not throw', () => { + const positioningRef = React.createRef(); + + const Fixture = () => ( +
+ + + + + Surface + +
+ ); + + cy.viewport(1000, 600); + mount(); + cy.get(surfaceSelector).should('be.visible'); + cy.then(() => { + expect(() => positioningRef.current?.updatePosition()).not.to.throw(); + }); + // Only assert the resolved placement when the browser supports CSS anchor positioning + // (Chromium 125+). Without it the surface stays at its static DOM position and the + // observer correctly reports whatever side it ends up on — not necessarily 'above'. + cy.window().then(win => { + if (win.CSS?.supports?.('anchor-name: --x')) { + cy.get(surfaceSelector).should('have.attr', 'data-placement', 'above'); + } + }); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/index.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/index.ts index 299ed046a07e76..f2d52d9a89aa0a 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/index.ts +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/index.ts @@ -1,12 +1,12 @@ +export { resolvePositioningShorthand } from '@fluentui/react-positioning'; export { usePositioning } from './usePositioning'; -export { getPlacementString, resolvePositioningShorthand } from './utils'; +export { getPlacementString } from './utils'; export { POSITIONS, ALIGNMENTS } from './constants'; -export type { PositioningReturn } from './types'; +export type { PositioningProps, PositioningReturn } from './types'; export type { Alignment, Position, PositioningImperativeRef, - PositioningProps, PositioningShorthand, PositioningShorthandValue, } from '@fluentui/react-positioning'; diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/types.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/types.ts index 89e3c0f391f900..5659e64d61cc1a 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/types.ts @@ -1,4 +1,5 @@ import type * as React from 'react'; +import type { PositioningProps as CanonicalPositioningProps } from '@fluentui/react-positioning'; export type LogicalAlignment = 'start' | 'center' | 'end'; @@ -6,3 +7,17 @@ export type PositioningReturn = { targetRef: React.RefCallback; containerRef: React.RefCallback; }; + +export type PositioningProps = Pick< + CanonicalPositioningProps, + | 'align' + | 'coverTarget' + | 'fallbackPositions' + | 'matchTargetSize' + | 'offset' + | 'pinned' + | 'position' + | 'positioningRef' + | 'strategy' + | 'target' +>; diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePlacementObserver.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePlacementObserver.ts index d08822d454d537..c58fed88c50562 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePlacementObserver.ts +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePlacementObserver.ts @@ -1,62 +1,14 @@ 'use client'; -import { useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; -import type { Position } from '@fluentui/react-positioning'; -import type { LogicalAlignment } from './types'; -import { ALIGNMENTS, POSITIONS } from './constants'; -import { getPlacementString } from './utils/placement'; - -const PLACEMENT_TOLERANCE = 2; - -const closeTo = (a: number, b: number): boolean => Math.abs(a - b) <= PLACEMENT_TOLERANCE; - -function detectPosition(surfaceRect: DOMRect, targetRect: DOMRect): Position | null { - if (surfaceRect.bottom <= targetRect.top + PLACEMENT_TOLERANCE) { - return POSITIONS.above; - } - - if (surfaceRect.top >= targetRect.bottom - PLACEMENT_TOLERANCE) { - return POSITIONS.below; - } - - if (surfaceRect.right <= targetRect.left + PLACEMENT_TOLERANCE) { - return POSITIONS.before; - } - - if (surfaceRect.left >= targetRect.right - PLACEMENT_TOLERANCE) { - return POSITIONS.after; - } - - return null; -} - -function detectAlign(position: Position, surfaceRect: DOMRect, targetRect: DOMRect): LogicalAlignment { - const isBlockMain = position === POSITIONS.above || position === POSITIONS.below; - - const startAligned = isBlockMain - ? closeTo(surfaceRect.left, targetRect.left) - : closeTo(surfaceRect.top, targetRect.top); - - if (startAligned) { - return ALIGNMENTS.start; - } - - const endAligned = isBlockMain - ? closeTo(surfaceRect.right, targetRect.right) - : closeTo(surfaceRect.bottom, targetRect.bottom); - - if (endAligned) { - return ALIGNMENTS.end; - } - - return ALIGNMENTS.center; -} +import { useIsomorphicLayoutEffect, useEventCallback } from '@fluentui/react-utilities'; +import { computePosition, debounce } from './utils'; /** - * Pure-observation hook: reads the rendered rects of the surface and anchor - * and mirrors the resolved placement into the surface's `data-placement` - * attribute. This keeps the attribute in sync with the browser's decision - * after native flip fires (scroll, resize, ResizeObserver tick). + * Mirrors the placement that the browser actually resolves for an + * anchored element into its `data-placement` attribute. Useful when CSS + * `position-try-fallbacks` flips the surface after a layout shift, scroll, + * or content reflow — consumers can style the surface (arrows, animations) + * via `[data-placement^="above"]` and friends and stay in sync. * */ export function usePlacementObserver( @@ -64,48 +16,58 @@ export function usePlacementObserver( targetEl: HTMLElement | null, targetDocument: Document | undefined, disabled = false, -): void { +): () => void { + const update = useEventCallback(() => { + if (!containerEl || !targetEl) { + return; + } + + const result = computePosition(targetEl, containerEl); + if (!result) { + return; + } + + if (containerEl.getAttribute('data-placement') !== result.placement) { + containerEl.setAttribute('data-placement', result.placement); + } + }); + useIsomorphicLayoutEffect(() => { if (disabled || !containerEl || !targetEl) { return; } const win = targetDocument?.defaultView; - if (!win) { return; } - const update = () => { - const surfaceRect = containerEl.getBoundingClientRect(); - const targetRect = targetEl.getBoundingClientRect(); - const position = detectPosition(surfaceRect, targetRect); + const debouncedUpdate = debounce(update); - if (!position) { - return; - } + const ResizeObserverCtor = win.ResizeObserver; + const resizeObserver = ResizeObserverCtor + ? new ResizeObserverCtor(entries => { + const allLaidOut = entries.every(entry => entry.contentRect.width > 0 && entry.contentRect.height > 0); + if (allLaidOut) { + debouncedUpdate(); + } + }) + : null; - const align = detectAlign(position, surfaceRect, targetRect); - const next = getPlacementString(position, align); + resizeObserver?.observe(containerEl); + resizeObserver?.observe(targetEl); - if (containerEl.getAttribute('data-placement') !== next) { - containerEl.setAttribute('data-placement', next); - } - }; + win.addEventListener('scroll', debouncedUpdate, { capture: true, passive: true }); + win.addEventListener('resize', debouncedUpdate); - update(); - - const ResizeObserverCtor = win.ResizeObserver; - const observer = ResizeObserverCtor ? new ResizeObserverCtor(update) : null; - observer?.observe(containerEl); - observer?.observe(targetEl); - win.addEventListener('scroll', update, true); - win.addEventListener('resize', update); + debouncedUpdate(); return () => { - observer?.disconnect(); - win.removeEventListener('scroll', update, true); - win.removeEventListener('resize', update); + resizeObserver?.disconnect(); + win.removeEventListener('scroll', debouncedUpdate, { capture: true }); + win.removeEventListener('resize', debouncedUpdate); }; - }, [containerEl, targetEl, targetDocument, disabled]); + }, [containerEl, targetEl, targetDocument, disabled, update]); + + return update; } diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.test.tsx b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.test.tsx index da160557099bca..7f849b3294e97a 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.test.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.test.tsx @@ -2,8 +2,7 @@ import * as React from 'react'; import { act, render } from '@testing-library/react'; import { usePositioning } from './usePositioning'; import { getPlacementString } from './utils/placement'; -import type { PositioningProps } from '@fluentui/react-positioning'; -import type { PositioningReturn } from './types'; +import type { PositioningProps, PositioningReturn } from './types'; function mountHook(options: PositioningProps = {}) { const resultRef = React.createRef<{ current: PositioningReturn }>(); @@ -160,7 +159,7 @@ describe('usePositioning', () => { expect(node).toHaveStyle({ width: 'anchor-size(width)' }); }); - it('containerRef applies offset as logical margins', () => { + it('containerRef applies offset as symmetric logical margins so flips keep their gap', () => { const result = mountHook({ position: 'below', offset: { mainAxis: 8, crossAxis: 4 } }); const node = document.createElement('div'); @@ -168,7 +167,27 @@ describe('usePositioning', () => { result.current.containerRef(node); }); - expect(node).toHaveStyle({ marginBlockStart: '8px', marginInlineStart: '4px' }); + expect(node).toHaveStyle({ + marginBlockStart: '8px', + marginBlockEnd: '8px', + marginInlineStart: '4px', + marginInlineEnd: '4px', + }); + }); + + describe('imperative ref', () => { + it('exposes a callable updatePosition()', () => { + const positioningRef = React.createRef<{ + updatePosition: () => void; + setTarget: (el: HTMLElement | null) => void; + }>(); + mountHook({ + positioningRef: positioningRef as unknown as PositioningProps['positioningRef'], + }); + + expect(positioningRef.current).not.toBeNull(); + expect(() => positioningRef.current?.updatePosition()).not.toThrow(); + }); }); }); diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.ts index 793d7af6079534..68bcd95c543733 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.ts +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.ts @@ -5,10 +5,10 @@ import { useId, useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; import type { PositioningImperativeRef, - PositioningProps, + PositioningShorthandValue, PositioningVirtualElement, } from '@fluentui/react-positioning'; -import type { PositioningReturn } from './types'; +import type { PositioningProps, PositioningReturn } from './types'; import { POSITIONS, ALIGNMENTS, POSITION_AREA_MAP } from './constants'; import { getPlacementString, normalizeAlign } from './utils/placement'; import { applyOffset, getCoverSelfAlignment, resolveElementRef, resolveOffset, shorthandToPositionArea } from './utils'; @@ -17,6 +17,7 @@ import { usePlacementObserver } from './usePlacementObserver'; export type TargetElement = HTMLElement | PositioningVirtualElement; const DEFAULT_FLIP = ['flip-block', 'flip-inline', 'flip-block flip-inline']; +const EMPTY_FALLBACK_POSITIONS: PositioningShorthandValue[] = []; export function usePositioning(options: PositioningProps): PositioningReturn { const { @@ -24,7 +25,7 @@ export function usePositioning(options: PositioningProps): PositioningReturn { target: customTarget = null, align: alignInput = ALIGNMENTS.center, position = POSITIONS.above, - fallbackPositions = [], + fallbackPositions = EMPTY_FALLBACK_POSITIONS, offset, coverTarget = false, strategy = 'absolute', @@ -35,7 +36,10 @@ export function usePositioning(options: PositioningProps): PositioningReturn { const align = normalizeAlign(alignInput); const { mainAxis, crossAxis } = resolveOffset(offset); - const coverAlignment = coverTarget ? getCoverSelfAlignment(position, align) : null; + const coverAlignment = React.useMemo( + () => (coverTarget ? getCoverSelfAlignment(position, align) : null), + [coverTarget, position, align], + ); const [triggerEl, setTriggerEl] = React.useState(null); const [containerEl, setContainerEl] = React.useState(null); @@ -50,15 +54,17 @@ export function usePositioning(options: PositioningProps): PositioningReturn { const fallbackAreas = React.useMemo(() => fallbackPositions.map(shorthandToPositionArea), [fallbackPositions]); + const requestPlacementUpdate = usePlacementObserver(containerEl, effectiveTarget, targetDocument, coverTarget); + React.useImperativeHandle( positioningRef, () => ({ setTarget: (el: TargetElement | null) => { setImperativeTarget(resolveElementRef(el)); }, - updatePosition: () => undefined, + updatePosition: requestPlacementUpdate, }), - [], + [requestPlacementUpdate], ); useIsomorphicLayoutEffect(() => { @@ -149,7 +155,5 @@ export function usePositioning(options: PositioningProps): PositioningReturn { ], ); - usePlacementObserver(containerEl, effectiveTarget, targetDocument, coverTarget); - return { targetRef, containerRef }; } diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/computePosition.test.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/computePosition.test.ts new file mode 100644 index 00000000000000..c8333edf769d09 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/computePosition.test.ts @@ -0,0 +1,90 @@ +import { computePosition } from './computePosition'; + +function makeRect(rect: Partial): DOMRect { + const { x = 0, y = 0, width = 0, height = 0 } = rect; + return { + x, + y, + width, + height, + top: rect.top ?? y, + left: rect.left ?? x, + right: rect.right ?? x + width, + bottom: rect.bottom ?? y + height, + toJSON: () => ({}), + } as DOMRect; +} + +function makeElement(rect: Partial): HTMLElement { + const el = document.createElement('div'); + el.getBoundingClientRect = () => makeRect(rect); + return el; +} + +describe('computePosition', () => { + it('returns above-center when floating sits directly above the reference', () => { + const reference = makeElement({ top: 200, left: 100, right: 200, bottom: 240, width: 100, height: 40 }); + const floating = makeElement({ top: 150, left: 125, right: 175, bottom: 190, width: 50, height: 40 }); + + const result = computePosition(reference, floating); + + expect(result).toEqual( + expect.objectContaining({ + position: 'above', + align: 'center', + placement: 'above', + }), + ); + }); + + it('returns below-start when floating is below and left-aligned with reference', () => { + const reference = makeElement({ top: 100, left: 100, right: 200, bottom: 140, width: 100, height: 40 }); + const floating = makeElement({ top: 150, left: 100, right: 160, bottom: 200, width: 60, height: 50 }); + + const result = computePosition(reference, floating); + expect(result?.position).toBe('below'); + expect(result?.align).toBe('start'); + expect(result?.placement).toBe('below-start'); + }); + + it('returns before-end when floating is to the left and bottom-aligned', () => { + const reference = makeElement({ top: 100, left: 200, right: 300, bottom: 200, width: 100, height: 100 }); + const floating = makeElement({ top: 120, left: 100, right: 195, bottom: 200, width: 95, height: 80 }); + + const result = computePosition(reference, floating); + expect(result?.position).toBe('before'); + expect(result?.align).toBe('end'); + expect(result?.placement).toBe('before-bottom'); + }); + + it('returns after when floating is to the right and centered', () => { + const reference = makeElement({ top: 100, left: 100, right: 200, bottom: 200, width: 100, height: 100 }); + const floating = makeElement({ top: 120, left: 210, right: 290, bottom: 180, width: 80, height: 60 }); + + const result = computePosition(reference, floating); + expect(result?.position).toBe('after'); + expect(result?.align).toBe('center'); + }); + + it('returns null when rects overlap and no clear side is determinable', () => { + const reference = makeElement({ top: 100, left: 100, right: 200, bottom: 200, width: 100, height: 100 }); + const floating = makeElement({ top: 150, left: 150, right: 250, bottom: 250, width: 100, height: 100 }); + + expect(computePosition(reference, floating)).toBeNull(); + }); + + it('absorbs subpixel mismatches up to the default tolerance (2px)', () => { + const reference = makeElement({ top: 200, left: 100, right: 200, bottom: 240, width: 100, height: 40 }); + const floating = makeElement({ top: 150, left: 125, right: 175, bottom: 201.5, width: 50, height: 51.5 }); + + expect(computePosition(reference, floating)?.position).toBe('above'); + }); + + it('respects a custom tolerance', () => { + const reference = makeElement({ top: 200, left: 100, right: 200, bottom: 240, width: 100, height: 40 }); + const floating = makeElement({ top: 150, left: 125, right: 175, bottom: 205, width: 50, height: 55 }); + + expect(computePosition(reference, floating)).toBeNull(); + expect(computePosition(reference, floating, { tolerance: 6 })?.position).toBe('above'); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/computePosition.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/computePosition.ts new file mode 100644 index 00000000000000..4d5ae7af803ab9 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/computePosition.ts @@ -0,0 +1,85 @@ +import type { Position, PositioningShorthandValue } from '@fluentui/react-positioning'; +import type { LogicalAlignment } from '../types'; +import { ALIGNMENTS, POSITIONS } from '../constants'; +import { getPlacementString } from './placement'; + +const DEFAULT_TOLERANCE = 2; + +const closeTo = (a: number, b: number, tolerance: number): boolean => Math.abs(a - b) <= tolerance; + +function detectPosition(floatingRect: DOMRect, referenceRect: DOMRect, tolerance: number): Position | null { + if (floatingRect.bottom <= referenceRect.top + tolerance) { + return POSITIONS.above; + } + + if (floatingRect.top >= referenceRect.bottom - tolerance) { + return POSITIONS.below; + } + + if (floatingRect.right <= referenceRect.left + tolerance) { + return POSITIONS.before; + } + + if (floatingRect.left >= referenceRect.right - tolerance) { + return POSITIONS.after; + } + + return null; +} + +function detectAlign( + position: Position, + floatingRect: DOMRect, + referenceRect: DOMRect, + tolerance: number, +): LogicalAlignment { + const isBlockMain = position === POSITIONS.above || position === POSITIONS.below; + + const startAligned = isBlockMain + ? closeTo(floatingRect.left, referenceRect.left, tolerance) + : closeTo(floatingRect.top, referenceRect.top, tolerance); + + if (startAligned) { + return ALIGNMENTS.start; + } + + const endAligned = isBlockMain + ? closeTo(floatingRect.right, referenceRect.right, tolerance) + : closeTo(floatingRect.bottom, referenceRect.bottom, tolerance); + + if (endAligned) { + return ALIGNMENTS.end; + } + + return ALIGNMENTS.center; +} + +export interface ComputePositionConfig { + tolerance?: number; +} + +export interface ComputePositionReturn { + position: Position; + align: LogicalAlignment; + placement: PositioningShorthandValue; +} + +export function computePosition( + reference: HTMLElement, + floating: HTMLElement, + config?: ComputePositionConfig, +): ComputePositionReturn | null { + const tolerance = config?.tolerance ?? DEFAULT_TOLERANCE; + const referenceRect = reference.getBoundingClientRect(); + const floatingRect = floating.getBoundingClientRect(); + + const position = detectPosition(floatingRect, referenceRect, tolerance); + if (!position) { + return null; + } + + const align = detectAlign(position, floatingRect, referenceRect, tolerance); + const placement = getPlacementString(position, align); + + return { position, align, placement }; +} diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/debounce.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/debounce.ts new file mode 100644 index 00000000000000..cc80d606118e2d --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/debounce.ts @@ -0,0 +1,18 @@ +/** + * Microtask debouncer: coalesces multiple synchronous calls within a task into + * a single invocation, fired before the next paint. Same shape as the helper + * `@fluentui/react-positioning` uses internally (originally from Popper.js v2). + */ +export function debounce(fn: () => void): () => void { + let pending = false; + return () => { + if (pending) { + return; + } + pending = true; + Promise.resolve().then(() => { + pending = false; + fn(); + }); + }; +} diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/index.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/index.ts index 78d00ae45eb4cf..8506fa430c6121 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/index.ts +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/index.ts @@ -1,8 +1,6 @@ +export { computePosition } from './computePosition'; +export type { ComputePositionConfig, ComputePositionReturn } from './computePosition'; +export { debounce } from './debounce'; export { applyOffset, resolveOffset } from './offset'; -export { - getCoverSelfAlignment, - getPlacementString, - resolvePositioningShorthand, - shorthandToPositionArea, -} from './placement'; +export { getCoverSelfAlignment, getPlacementString, shorthandToPositionArea } from './placement'; export { resolveElementRef } from './resolveElementRef'; diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/offset.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/offset.ts index 2a8537873de625..940956f736d989 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/offset.ts +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/offset.ts @@ -2,33 +2,27 @@ import type { Position, PositioningProps } from '@fluentui/react-positioning'; import { POSITIONS } from '../constants'; export function applyOffset(node: HTMLElement, position: Position, mainAxis: number, crossAxis: number): void { + const isBlockMain = position === POSITIONS.above || position === POSITIONS.below; + if (mainAxis) { - switch (position) { - case POSITIONS.above: - node.style.marginBlockEnd = `${mainAxis}px`; - break; - case POSITIONS.below: - node.style.marginBlockStart = `${mainAxis}px`; - break; - case POSITIONS.before: - node.style.marginInlineEnd = `${mainAxis}px`; - break; - case POSITIONS.after: - node.style.marginInlineStart = `${mainAxis}px`; - break; + const main = `${mainAxis}px`; + if (isBlockMain) { + node.style.marginBlockStart = main; + node.style.marginBlockEnd = main; + } else { + node.style.marginInlineStart = main; + node.style.marginInlineEnd = main; } } if (crossAxis) { - switch (position) { - case POSITIONS.above: - case POSITIONS.below: - node.style.marginInlineStart = `${crossAxis}px`; - break; - case POSITIONS.before: - case POSITIONS.after: - node.style.marginBlockStart = `${crossAxis}px`; - break; + const cross = `${crossAxis}px`; + if (isBlockMain) { + node.style.marginInlineStart = cross; + node.style.marginInlineEnd = cross; + } else { + node.style.marginBlockStart = cross; + node.style.marginBlockEnd = cross; } } } diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/placement.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/placement.ts index 08d5b0a43eba52..812e5919fd1238 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/placement.ts +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/placement.ts @@ -1,10 +1,8 @@ import { resolvePositioningShorthand } from '@fluentui/react-positioning'; -import type { Position, PositioningShorthandValue } from '@fluentui/react-positioning'; +import type { Alignment, Position, PositioningShorthandValue } from '@fluentui/react-positioning'; import type { LogicalAlignment } from '../types'; import { ALIGNMENTS, POSITIONS, POSITION_AREA_MAP } from '../constants'; -export { resolvePositioningShorthand }; - const ALIGN_ALIASES: Record = { top: ALIGNMENTS.start, bottom: ALIGNMENTS.end, @@ -19,22 +17,23 @@ export function normalizeAlign(raw: string): LogicalAlignment { } /** - * Maps (position, align) into the human-readable placement string used for the - * `data-placement` attribute. Center alignment renders as the bare position. - * - * For horizontal positions (`before`/`after`) the alignment is rendered using - * physical names (`top`/`bottom`) to match react-positioning's convention. + * Maps (position, align) into the placement value used for the `data-placement` + * attribute. Center alignment renders as the bare position; horizontal positions + * (`before`/`after`) render their alignment as physical (`top`/`bottom`) to + * match react-positioning's convention. */ -export function getPlacementString(position: Position, align: LogicalAlignment): string { - if (align === ALIGNMENTS.center) { +export function getPlacementString(position: Position, align: Alignment): PositioningShorthandValue { + const logical = normalizeAlign(align); + + if (logical === ALIGNMENTS.center) { return position; } if (position === POSITIONS.before || position === POSITIONS.after) { - return `${position}-${align === ALIGNMENTS.start ? 'top' : 'bottom'}`; + return `${position}-${logical === ALIGNMENTS.start ? 'top' : 'bottom'}`; } - return `${position}-${align}`; + return `${position}-${logical}`; } export function shorthandToPositionArea(shorthand: PositioningShorthandValue): string {