Skip to content

Commit 8db0ab2

Browse files
committed
fixup! refactor(react-headless-components-preview): rebuild usePositioning on spec-pure CSS anchor positioning
1 parent a8bffc5 commit 8db0ab2

25 files changed

Lines changed: 326 additions & 211 deletions

packages/react-components/react-headless-components-preview/library/docs/popover-spec.md

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -161,21 +161,21 @@ Placement is handled entirely by the `usePositioning` hook, which writes native
161161

162162
### Options (all optional)
163163

164-
| Option | Type | Default | Effect |
165-
| ------------------------- | ----------------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
166-
| `position` | `'above' \| 'below' \| 'before' \| 'after'` | `'above'` | Which side of the anchor the surface sits on. Physical `top` / `bottom` / `left` / `right` are normalized. |
167-
| `align` | `'start' \| 'center' \| 'end' \| 'top' \| 'bottom'` | `'center'` | Cross-axis alignment. `top``start`, `bottom``end` (v9 aliases). |
168-
| `offset` | `number \| { mainAxis?: number; crossAxis?: number }` | `0` | Logical-margin offset from the anchor. |
169-
| `fallbackPositions` | `PositioningShorthandValue[]` | `[]` | Custom fallback chain. Each entry is converted to a `<position-area>` value inline in `position-try-fallbacks`. |
170-
| `coverTarget` | `boolean` | `false` | Overlap the anchor instead of sitting beside it. |
171-
| `pinned` | `boolean` | `false` | Disable fallback flipping; surface stays at the requested placement even if it overflows. |
172-
| `matchTargetSize` | `'width'` || Sets the surface's `width` to `anchor-size(width)`. |
173-
| `strategy` | `'fixed' \| 'absolute'` | `'fixed'` | CSS `position` property value on the surface. |
174-
| `target` | `HTMLElement \| RefObject` || Custom anchor element. When set, `anchor-name` is written on this element instead of the trigger. |
175-
| `positioningRef` | `Ref<PositioningImperativeRef>` || `{ setTarget(el): void; updatePosition(): void }`. `updatePosition` is a no-op — native positioning self-updates. |
176-
| `autoSize` | `boolean \| 'width' \| 'height'` | `false` | Cap the surface dimensions against `overflowBoundary`. Requires `overflowBoundary` — pure-CSS autoSize isn't possible due to spec-level restrictions on `anchor()` in `max-*`. |
177-
| `overflowBoundary` | `HTMLElement \| RefObject` || Element whose rect is used for `autoSize`'s JS-measured `max-width` / `max-height` caps. |
178-
| `overflowBoundaryPadding` | `number \| { top, end, bottom, start }` || Pixels of padding applied inward from the `overflowBoundary` rect before `autoSize`'s math. Accepts a single number for uniform padding or a logical-side object (RTL-aware). No effect when `overflowBoundary` / `autoSize` isn't set. |
164+
| Option | Type | Default | Effect |
165+
| ------------------------- | ----------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
166+
| `position` | `'above' \| 'below' \| 'before' \| 'after'` | `'above'` | Which side of the anchor the surface sits on. Physical `top` / `bottom` / `left` / `right` are normalized. |
167+
| `align` | `'start' \| 'center' \| 'end' \| 'top' \| 'bottom'` | `'center'` | Cross-axis alignment. `top``start`, `bottom``end` (v9 aliases). |
168+
| `offset` | `number \| { mainAxis?: number; crossAxis?: number }` | `0` | Logical-margin offset from the anchor. |
169+
| `fallbackPositions` | `PositioningShorthandValue[]` | `[]` | Custom fallback chain. Each entry is converted to a `<position-area>` value inline in `position-try-fallbacks`. |
170+
| `coverTarget` | `boolean` | `false` | Overlap the anchor instead of sitting beside it. |
171+
| `pinned` | `boolean` | `false` | Disable fallback flipping; surface stays at the requested placement even if it overflows. |
172+
| `matchTargetSize` | `'width'` || Sets the surface's `width` to `anchor-size(width)`. |
173+
| `strategy` | `'fixed' \| 'absolute'` | `'fixed'` | CSS `position` property value on the surface. |
174+
| `target` | `HTMLElement \| RefObject` || Custom anchor element. When set, `anchor-name` is written on this element instead of the trigger. |
175+
| `positioningRef` | `Ref<PositioningImperativeRef>` || `{ setTarget(el): void; updatePosition(): void }`. `updatePosition` is a no-op — native positioning self-updates. |
176+
| `autoSize` | `boolean \| 'width' \| 'height'` | `false` | Cap the surface dimensions against `overflowBoundary`. Requires `overflowBoundary` — pure-CSS autoSize isn't possible due to spec-level restrictions on `anchor()` in `max-*`. |
177+
| `overflowBoundary` | `HTMLElement \| RefObject` || Element whose rect the surface must stay inside. Drives a JS-measured `transform: translate3d()` **cross-axis shift** so the surface never exceeds the boundary on the cross axis of its primary placement (matches v9 / Floating UI's `shift()` middleware). When `autoSize` is also set, this same rect is used to compute the `max-*` caps. The two features are independent — set either alone or both together. |
178+
| `overflowBoundaryPadding` | `number \| { top, end, bottom, start }` || Breathing room kept between the surface and the `overflowBoundary` rect. Implemented as a JS-measured `transform: translate3d()` **cross-axis shift** on the surface (only — main-axis overflow is native flip's job, matching v9 / Floating UI's `shift()` middleware). Does not affect the surface's size. Accepts a uniform number or a logical-side object (RTL-aware). Has no effect when `overflowBoundary` is unset or when `coverTarget` is on. |
179179

180180
### Rendering
181181

packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useAutoSizeBoundary.ts

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,11 @@
22

33
import { useIsomorphicLayoutEffect } from '@fluentui/react-utilities';
44
import type { Position, LogicalAlignment, PositioningProps } from './types';
5-
import {
6-
applyBoundaryPadding,
7-
computeAvailableHeight,
8-
computeAvailableWidth,
9-
resolveBoundaryPadding,
10-
resolveElementRef,
11-
} from './utils';
5+
import { computeAvailableHeight, computeAvailableWidth, resolveElementRef } from './utils';
126

137
export type UseAutoSizeBoundaryOptions = {
148
autoSize: PositioningProps['autoSize'];
159
overflowBoundary: PositioningProps['overflowBoundary'];
16-
overflowBoundaryPadding: PositioningProps['overflowBoundaryPadding'];
1710
containerEl: HTMLElement | null;
1811
targetEl: HTMLElement | null;
1912
position: Position;
@@ -24,7 +17,6 @@ export type UseAutoSizeBoundaryOptions = {
2417
export function useAutoSizeBoundary({
2518
autoSize,
2619
overflowBoundary,
27-
overflowBoundaryPadding,
2820
containerEl,
2921
targetEl,
3022
position,
@@ -52,17 +44,14 @@ export function useAutoSizeBoundary({
5244
const apply = () => {
5345
const boundaryRect = boundary.getBoundingClientRect();
5446
const triggerRect = trigger.getBoundingClientRect();
55-
const direction = targetDocument?.defaultView?.getComputedStyle(surface).direction === 'rtl' ? 'rtl' : 'ltr';
56-
const padding = resolveBoundaryPadding(overflowBoundaryPadding, direction);
57-
const paddedBoundary = applyBoundaryPadding(boundaryRect, padding);
5847

5948
if (applyHeight) {
60-
const available = computeAvailableHeight(position, align, paddedBoundary, triggerRect);
49+
const available = computeAvailableHeight(position, align, boundaryRect, triggerRect);
6150
surface.style.maxHeight = `${Math.max(0, available)}px`;
6251
}
6352

6453
if (applyWidth) {
65-
const available = computeAvailableWidth(position, align, paddedBoundary, triggerRect);
54+
const available = computeAvailableWidth(position, align, boundaryRect, triggerRect);
6655
surface.style.maxWidth = `${Math.max(0, available)}px`;
6756
}
6857
};
@@ -78,5 +67,5 @@ export function useAutoSizeBoundary({
7867
surface.style.removeProperty('max-height');
7968
surface.style.removeProperty('max-width');
8069
};
81-
}, [autoSize, overflowBoundary, overflowBoundaryPadding, containerEl, targetEl, position, align, targetDocument]);
70+
}, [autoSize, overflowBoundary, containerEl, targetEl, position, align, targetDocument]);
8271
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
'use client';
2+
3+
import { useIsomorphicLayoutEffect } from '@fluentui/react-utilities';
4+
import { POSITIONS } from './constants';
5+
import type { Position, PositioningProps } from './types';
6+
import { resolveBoundaryPadding, resolveElementRef } from './utils';
7+
8+
type UseOverflowShiftOptions = {
9+
overflowBoundary: PositioningProps['overflowBoundary'];
10+
overflowBoundaryPadding: PositioningProps['overflowBoundaryPadding'];
11+
position: Position;
12+
coverTarget: boolean;
13+
containerEl: HTMLElement | null;
14+
targetEl: HTMLElement | null;
15+
targetDocument: Document | undefined;
16+
};
17+
18+
/**
19+
* Keeps the surface inside the `overflowBoundary` rect by writing
20+
* `transform: translate3d(dx, dy, 0)` on the surface. Runs whenever
21+
* `overflowBoundary` is set — `overflowBoundaryPadding` is an optional
22+
* modifier that adds extra breathing room (defaults to 0). Measures rects
23+
* on every ResizeObserver / scroll / resize tick so the shift stays
24+
* accurate as the user scrolls the page or resizes the boundary.
25+
*
26+
* Mirrors v9 + Floating UI's `shift()` middleware — which is driven by
27+
* `overflowBoundary` alone, with `overflowBoundaryPadding` as a modifier.
28+
* Shift runs only on the **cross axis** of the primary placement;
29+
* main-axis overflow is native flip's job.
30+
*/
31+
export function useOverflowShift({
32+
overflowBoundary,
33+
overflowBoundaryPadding,
34+
position,
35+
coverTarget,
36+
containerEl,
37+
targetEl,
38+
targetDocument,
39+
}: UseOverflowShiftOptions): void {
40+
useIsomorphicLayoutEffect(() => {
41+
if (coverTarget || !containerEl || !targetEl) {
42+
return;
43+
}
44+
45+
const boundary = resolveElementRef(overflowBoundary);
46+
const view = targetDocument?.defaultView;
47+
const ResizeObserverCtor = view?.ResizeObserver;
48+
49+
if (!boundary || !view || !ResizeObserverCtor) {
50+
return;
51+
}
52+
53+
const surface = containerEl;
54+
55+
const apply = () => {
56+
const previous = surface.style.transform;
57+
surface.style.transform = '';
58+
const surfaceRect = surface.getBoundingClientRect();
59+
surface.style.transform = previous;
60+
61+
const boundaryRect = boundary.getBoundingClientRect();
62+
const direction = view.getComputedStyle(surface).direction === 'rtl' ? 'rtl' : 'ltr';
63+
const padding = resolveBoundaryPadding(overflowBoundaryPadding, direction);
64+
65+
const isBlockAxisPrimary = position === POSITIONS.above || position === POSITIONS.below;
66+
67+
let dx = 0;
68+
let dy = 0;
69+
70+
if (isBlockAxisPrimary) {
71+
const leftOverflow = boundaryRect.left + padding.left - surfaceRect.left;
72+
const rightOverflow = surfaceRect.right - (boundaryRect.right - padding.right);
73+
74+
if (leftOverflow > 0) {
75+
dx = leftOverflow;
76+
} else if (rightOverflow > 0) {
77+
dx = -rightOverflow;
78+
}
79+
} else {
80+
const topOverflow = boundaryRect.top + padding.top - surfaceRect.top;
81+
const bottomOverflow = surfaceRect.bottom - (boundaryRect.bottom - padding.bottom);
82+
83+
if (topOverflow > 0) {
84+
dy = topOverflow;
85+
} else if (bottomOverflow > 0) {
86+
dy = -bottomOverflow;
87+
}
88+
}
89+
90+
if (dx === 0 && dy === 0) {
91+
surface.style.removeProperty('transform');
92+
} else {
93+
surface.style.setProperty('transform', `translate3d(${dx}px, ${dy}px, 0)`);
94+
}
95+
};
96+
97+
apply();
98+
99+
const observer = new ResizeObserverCtor(apply);
100+
observer.observe(surface);
101+
observer.observe(boundary);
102+
observer.observe(targetEl);
103+
104+
view.addEventListener('scroll', apply, true);
105+
view.addEventListener('resize', apply);
106+
107+
return () => {
108+
observer.disconnect();
109+
view.removeEventListener('scroll', apply, true);
110+
view.removeEventListener('resize', apply);
111+
surface.style.removeProperty('transform');
112+
};
113+
}, [overflowBoundary, overflowBoundaryPadding, position, coverTarget, containerEl, targetEl, targetDocument]);
114+
}

0 commit comments

Comments
 (0)