Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "feat(react-headless-components-preview): hybrid usePositioning that prefers CSS Anchor Positioning and lazy-loads floating-ui as a fallback",
"packageName": "@fluentui/react-headless-components-preview",
"email": "dmytrokirpa@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export { PositioningProps }
export type PositioningReturn = {
targetRef: React_2.RefCallback<HTMLElement>;
containerRef: React_2.RefCallback<HTMLElement>;
arrowRef: React_2.RefCallback<HTMLElement>;
};

export { PositioningShorthand }
Expand All @@ -49,9 +50,12 @@ export const POSITIONS: {
readonly after: "after";
};

// @public
export function preloadPositioning(): Promise<unknown>;

export { resolvePositioningShorthand }

// @public (undocumented)
// @public
export function usePositioning(options: PositioningProps): PositioningReturn;

// (No @packageDocumentation comment for this package)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"@fluentui/react-toolbar": "^9.8.0",
"@fluentui/react-tooltip": "^9.10.1",
"@fluentui/react-utilities": "^9.26.3",
"@floating-ui/dom": "^1.6.12",
"@swc/helpers": "^0.5.1"
},
"peerDependencies": {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export { usePositioning } from './usePositioning';
export { usePositioning, preloadPositioning } from './usePositioning';
export type {
Position,
Alignment,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
import type { PositioningProps } from '@fluentui/react-positioning';
import { ALIGNMENTS, POSITIONS, POSITION_AREA_MAP } from './constants';
import {
applyOffset,
getCoverSelfAlignment,
getPlacementString,
resolveOffset,
shorthandToPositionArea,
} from './utils';
import { normalizeAlign } from './utils/placement';
import type { LogicalAlignment } from './types';

// alert('Anchor Positioning');

const DEFAULT_FLIP = ['flip-block', 'flip-inline', 'flip-block flip-inline'];

const PLACEMENT_TOLERANCE = 2;

const closeTo = (a: number, b: number) => Math.abs(a - b) <= PLACEMENT_TOLERANCE;

function detectPosition(surfaceRect: DOMRect, targetRect: DOMRect) {
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: 'above' | 'below' | 'before' | 'after',
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;
}

const ANCHOR_PROPS = [
'position',
'inset',
'margin',
'margin-block-start',
'margin-block-end',
'margin-inline-start',
'margin-inline-end',
'width',
'position-anchor',
'position-area',
'place-self',
'align-self',
'justify-self',
'position-try-fallbacks',
'visibility',
] as const;

const ARROW_PROPS = ['position', 'position-anchor', 'top', 'right', 'bottom', 'left', 'translate'] as const;

export interface ApplyAnchorPositioningArgs {
target: HTMLElement;
container: HTMLElement;
arrow: HTMLElement | null;
anchorName: string;
options: PositioningProps;
targetDocument: Document | undefined;
}

/**
* Applies CSS Anchor Positioning to the container, anchoring it to the target.
* All work is imperative — no React. Returns a cleanup that reverses every
* style mutation and removes any subscribed observers.
*/
export function applyAnchorPositioning({
target,
container,
arrow,
anchorName,
options,
targetDocument,
}: ApplyAnchorPositioningArgs): () => void {
const align = normalizeAlign(options.align ?? ALIGNMENTS.center);
const position = options.position ?? POSITIONS.above;
const { mainAxis, crossAxis } = resolveOffset(options.offset);
const coverAlignment = options.coverTarget ? getCoverSelfAlignment(position, align) : null;
const positionArea = POSITION_AREA_MAP[position][align];
const placement = getPlacementString(position, align);
const fallbackAreas = (options.fallbackPositions ?? []).map(shorthandToPositionArea);
const strategy = options.strategy ?? 'absolute';

// Anchor name on the target.
target.style.setProperty('anchor-name', anchorName);

// Container styles.
container.style.setProperty('position', strategy);
container.style.setProperty('inset', 'auto');
container.style.setProperty('margin', '0');

applyOffset(container, position, mainAxis, crossAxis);

if (options.matchTargetSize === 'width') {
container.style.setProperty('width', 'anchor-size(width)');
} else {
container.style.removeProperty('width');
}

container.style.setProperty('position-anchor', anchorName);
container.setAttribute('data-placement', placement);

if (coverAlignment) {
container.style.setProperty('position-area', 'center');
container.style.setProperty('align-self', coverAlignment.alignSelf);
container.style.setProperty('justify-self', coverAlignment.justifySelf);
container.style.removeProperty('position-try-fallbacks');
} else {
container.style.setProperty('position-area', positionArea);

// Workaround for https://crbug.com/438334710: Chromium (<=130-ish) doesn't
// apply implicit `anchor-center` self-alignment for single-keyword
// `position-area` values.
if (align === ALIGNMENTS.center) {
container.style.setProperty('place-self', 'anchor-center');
} else {
container.style.removeProperty('place-self');
container.style.removeProperty('align-self');
container.style.removeProperty('justify-self');
}

if (options.pinned) {
container.style.removeProperty('position-try-fallbacks');
} else if (fallbackAreas.length > 0) {
container.style.setProperty('position-try-fallbacks', fallbackAreas.join(', '));
} else {
container.style.setProperty('position-try-fallbacks', DEFAULT_FLIP.join(', '));
}
}

// Mirror the browser-resolved placement into data-placement after flip fires.
const observerCleanup = observePlacement(container, target, targetDocument, !!options.coverTarget);

// Position the arrow (if any) at the popover edge nearest the trigger,
// centered on the trigger's cross-axis. The arrow becomes a CSS-anchored
// element pointing at the same trigger, so `anchor(center)` resolves to the
// trigger's center coordinate translated into the popover's containing
// block.
if (arrow) {
applyArrowAnchor(arrow, anchorName, position);
}

// Reveal the container — `usePositioning` hid it before scheduling this
// helper to avoid a flash at the default location while the chunk loaded.
// CSS Anchor Positioning resolves before the next paint, so by the time
// this line runs the anchored position is already what the browser will
// render.
container.style.removeProperty('visibility');

return () => {
observerCleanup();
target.style.removeProperty('anchor-name');
container.removeAttribute('data-placement');
for (const prop of ANCHOR_PROPS) {
container.style.removeProperty(prop);
}
if (arrow) {
for (const prop of ARROW_PROPS) {
arrow.style.removeProperty(prop);
}
}
};
}

function applyArrowAnchor(
arrow: HTMLElement,
anchorName: string,
position: 'above' | 'below' | 'before' | 'after',
): void {
arrow.style.setProperty('position', 'absolute');
arrow.style.setProperty('position-anchor', anchorName);

// Reset any leftover edge offsets from a previous placement.
arrow.style.removeProperty('top');
arrow.style.removeProperty('right');
arrow.style.removeProperty('bottom');
arrow.style.removeProperty('left');

switch (position) {
case POSITIONS.above:
arrow.style.setProperty('bottom', '0');
arrow.style.setProperty('left', 'anchor(center)');
arrow.style.setProperty('translate', '-50% 50%');
break;
case POSITIONS.below:
arrow.style.setProperty('top', '0');
arrow.style.setProperty('left', 'anchor(center)');
arrow.style.setProperty('translate', '-50% -50%');
break;
case POSITIONS.before:
arrow.style.setProperty('right', '0');
arrow.style.setProperty('top', 'anchor(center)');
arrow.style.setProperty('translate', '50% -50%');
break;
case POSITIONS.after:
arrow.style.setProperty('left', '0');
arrow.style.setProperty('top', 'anchor(center)');
arrow.style.setProperty('translate', '-50% -50%');
break;
}
}

function observePlacement(
container: HTMLElement,
target: HTMLElement,
targetDocument: Document | undefined,
disabled: boolean,
): () => void {
if (disabled) {
return () => undefined;
}

const win = targetDocument?.defaultView;
if (!win) {
return () => undefined;
}

const update = () => {
const surfaceRect = container.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
const detectedPosition = detectPosition(surfaceRect, targetRect);
if (!detectedPosition) {
return;
}
const detectedAlign = detectAlign(detectedPosition, surfaceRect, targetRect);
const next = getPlacementString(detectedPosition, detectedAlign);
if (container.getAttribute('data-placement') !== next) {
container.setAttribute('data-placement', next);
}
};

update();

const ResizeObserverCtor = win.ResizeObserver;
const observer = ResizeObserverCtor ? new ResizeObserverCtor(update) : null;
observer?.observe(container);
observer?.observe(target);
win.addEventListener('scroll', update, true);
win.addEventListener('resize', update);

return () => {
observer?.disconnect();
win.removeEventListener('scroll', update, true);
win.removeEventListener('resize', update);
};
}
Loading
Loading