Skip to content

Commit a8bffc5

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

14 files changed

Lines changed: 265 additions & 37 deletions

File tree

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

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -161,20 +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. |
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. |
178179

179180
### Rendering
180181

packages/react-components/react-headless-components-preview/library/etc/react-headless-components-preview.api.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,14 @@ export type PopoverTriggerState = {
562562
// @public (undocumented)
563563
export type Position = 'above' | 'below' | 'before' | 'after';
564564

565+
// @public
566+
export type PositioningBoundaryPadding = number | Partial<{
567+
top: number;
568+
end: number;
569+
bottom: number;
570+
start: number;
571+
}>;
572+
565573
// @public
566574
export type PositioningImperativeRef = {
567575
setTarget: (target: HTMLElement | null) => void;
@@ -585,6 +593,7 @@ export type PositioningProps = {
585593
pinned?: boolean;
586594
positioningRef?: React_2.Ref<PositioningImperativeRef>;
587595
overflowBoundary?: HTMLElement | React_2.RefObject<HTMLElement | null> | null;
596+
overflowBoundaryPadding?: PositioningBoundaryPadding;
588597
};
589598

590599
// @public (undocumented)

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ export type {
99
PositioningImperativeRef,
1010
PositioningShorthand,
1111
PositioningShorthandValue,
12+
PositioningBoundaryPadding,
1213
} from './types';

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,24 @@ export type Position = 'above' | 'below' | 'before' | 'after';
44
export type Alignment = 'top' | 'bottom' | 'start' | 'end' | 'center';
55
export type LogicalAlignment = 'start' | 'center' | 'end';
66

7+
/**
8+
* Padding around the `overflowBoundary` rect. Applied *inside* the boundary
9+
* before the `autoSize` math runs — equivalent to shrinking the boundary
10+
* inward by the given amount on each side.
11+
*
12+
* Accepts either a single number (same padding on all sides) or a logical
13+
* object (`top`, `end`, `bottom`, `start` — RTL-aware).
14+
*/
15+
export type PositioningBoundaryPadding = number | Partial<{ top: number; end: number; bottom: number; start: number }>;
16+
17+
/** Resolved padding: four physical sides, always fully populated. */
18+
export type ResolvedBoundaryPadding = {
19+
top: number;
20+
right: number;
21+
bottom: number;
22+
left: number;
23+
};
24+
725
/**
826
* Imperative API exposed via `PositioningProps.positioningRef`.
927
*/
@@ -85,6 +103,15 @@ export type PositioningProps = {
85103
* are computed from this element's rect. Accepts a DOM element or ref.
86104
*/
87105
overflowBoundary?: HTMLElement | React.RefObject<HTMLElement | null> | null;
106+
/**
107+
* Padding (in pixels) applied inward from the `overflowBoundary` rect
108+
* before `autoSize`'s `max-width` / `max-height` math runs. Accepts a
109+
* single number for uniform padding, or a logical-side object
110+
* (`{ top, end, bottom, start }`) for per-side control.
111+
*
112+
* Has no effect when `overflowBoundary` or `autoSize` is unset.
113+
*/
114+
overflowBoundaryPadding?: PositioningBoundaryPadding;
88115
};
89116

90117
export type PositioningShorthand = PositioningProps | PositioningShorthandValue;

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

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

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

713
export type UseAutoSizeBoundaryOptions = {
814
autoSize: PositioningProps['autoSize'];
915
overflowBoundary: PositioningProps['overflowBoundary'];
16+
overflowBoundaryPadding: PositioningProps['overflowBoundaryPadding'];
1017
containerEl: HTMLElement | null;
1118
targetEl: HTMLElement | null;
1219
position: Position;
@@ -17,6 +24,7 @@ export type UseAutoSizeBoundaryOptions = {
1724
export function useAutoSizeBoundary({
1825
autoSize,
1926
overflowBoundary,
27+
overflowBoundaryPadding,
2028
containerEl,
2129
targetEl,
2230
position,
@@ -44,14 +52,17 @@ export function useAutoSizeBoundary({
4452
const apply = () => {
4553
const boundaryRect = boundary.getBoundingClientRect();
4654
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);
4758

4859
if (applyHeight) {
49-
const available = computeAvailableHeight(position, align, boundaryRect, triggerRect);
60+
const available = computeAvailableHeight(position, align, paddedBoundary, triggerRect);
5061
surface.style.maxHeight = `${Math.max(0, available)}px`;
5162
}
5263

5364
if (applyWidth) {
54-
const available = computeAvailableWidth(position, align, boundaryRect, triggerRect);
65+
const available = computeAvailableWidth(position, align, paddedBoundary, triggerRect);
5566
surface.style.maxWidth = `${Math.max(0, available)}px`;
5667
}
5768
};
@@ -67,5 +78,5 @@ export function useAutoSizeBoundary({
6778
surface.style.removeProperty('max-height');
6879
surface.style.removeProperty('max-width');
6980
};
70-
}, [autoSize, overflowBoundary, containerEl, targetEl, position, align, targetDocument]);
81+
}, [autoSize, overflowBoundary, overflowBoundaryPadding, containerEl, targetEl, position, align, targetDocument]);
7182
}

packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.test.tsx

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as React from 'react';
22
import { act, render } from '@testing-library/react';
33
import { usePositioning } from './usePositioning';
44
import { getPlacementString } from './utils/placement';
5+
import { applyBoundaryPadding, resolveBoundaryPadding } from './utils/boundaryPadding';
56
import type { PositioningProps, PositioningReturn } from './types';
67

78
/**
@@ -147,6 +148,60 @@ describe('usePositioning', () => {
147148
});
148149
});
149150

151+
describe('resolveBoundaryPadding', () => {
152+
it('returns all-zero when padding is undefined', () => {
153+
expect(resolveBoundaryPadding(undefined, 'ltr')).toEqual({ top: 0, right: 0, bottom: 0, left: 0 });
154+
});
155+
156+
it('spreads a number across all four sides', () => {
157+
expect(resolveBoundaryPadding(8, 'ltr')).toEqual({ top: 8, right: 8, bottom: 8, left: 8 });
158+
});
159+
160+
it('maps logical start/end to left/right in LTR', () => {
161+
expect(resolveBoundaryPadding({ top: 1, end: 2, bottom: 3, start: 4 }, 'ltr')).toEqual({
162+
top: 1,
163+
right: 2,
164+
bottom: 3,
165+
left: 4,
166+
});
167+
});
168+
169+
it('maps logical start/end to right/left in RTL', () => {
170+
expect(resolveBoundaryPadding({ top: 1, end: 2, bottom: 3, start: 4 }, 'rtl')).toEqual({
171+
top: 1,
172+
right: 4,
173+
bottom: 3,
174+
left: 2,
175+
});
176+
});
177+
178+
it('defaults missing sides to 0', () => {
179+
expect(resolveBoundaryPadding({ top: 10 }, 'ltr')).toEqual({ top: 10, right: 0, bottom: 0, left: 0 });
180+
});
181+
});
182+
183+
describe('applyBoundaryPadding', () => {
184+
it('shrinks the rect inward on every side', () => {
185+
const rect = {
186+
top: 0,
187+
right: 100,
188+
bottom: 200,
189+
left: 0,
190+
width: 100,
191+
height: 200,
192+
} as DOMRect;
193+
194+
expect(applyBoundaryPadding(rect, { top: 5, right: 10, bottom: 15, left: 20 })).toEqual({
195+
top: 5,
196+
right: 90,
197+
bottom: 185,
198+
left: 20,
199+
width: 70,
200+
height: 180,
201+
});
202+
});
203+
});
204+
150205
describe('getPlacementString', () => {
151206
it('returns the bare position for center alignment', () => {
152207
expect(getPlacementString('above', 'center')).toBe('above');

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,12 @@ export function usePositioning(options: PositioningProps): PositioningReturn {
2323
coverTarget = false,
2424
autoSize,
2525
overflowBoundary,
26+
overflowBoundaryPadding,
2627
strategy = 'fixed',
2728
matchTargetSize,
2829
positioningRef,
2930
} = options;
3031

31-
// Accept both logical (`start`/`end`) and v9 physical (`top`/`bottom`) align
32-
// values for API parity; narrow to logical before using downstream.
3332
const align = normalizeAlign(alignInput);
3433

3534
const { mainAxis, crossAxis } = resolveOffset(offset);
@@ -146,6 +145,7 @@ export function usePositioning(options: PositioningProps): PositioningReturn {
146145
useAutoSizeBoundary({
147146
autoSize,
148147
overflowBoundary,
148+
overflowBoundaryPadding,
149149
containerEl,
150150
targetEl: effectiveTarget,
151151
position,

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Position, LogicalAlignment } from '../types';
22
import { ALIGNMENTS, GAP, POSITIONS } from '../constants';
3+
import type { BoundaryRectLike } from './boundaryPadding';
34

45
/**
56
* Available block-axis space between the trigger and the boundary, accounting
@@ -9,7 +10,7 @@ import { ALIGNMENTS, GAP, POSITIONS } from '../constants';
910
export function computeAvailableHeight(
1011
position: Position,
1112
align: LogicalAlignment,
12-
boundaryRect: DOMRect,
13+
boundaryRect: BoundaryRectLike,
1314
triggerRect: DOMRect,
1415
): number {
1516
if (position === POSITIONS.above) {
@@ -37,7 +38,7 @@ export function computeAvailableHeight(
3738
export function computeAvailableWidth(
3839
position: Position,
3940
align: LogicalAlignment,
40-
boundaryRect: DOMRect,
41+
boundaryRect: BoundaryRectLike,
4142
triggerRect: DOMRect,
4243
): number {
4344
if (position === POSITIONS.before) {
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { PositioningBoundaryPadding, ResolvedBoundaryPadding } from '../types';
2+
3+
const ZERO: ResolvedBoundaryPadding = { top: 0, right: 0, bottom: 0, left: 0 };
4+
5+
/** Shape accepted by the autoSize `compute*` helpers. `DOMRect` satisfies it. */
6+
export type BoundaryRectLike = Pick<DOMRect, 'top' | 'right' | 'bottom' | 'left' | 'width' | 'height'>;
7+
8+
/**
9+
* Resolves `overflowBoundaryPadding` (number or logical object) into a
10+
* physical four-side record. Logical `start`/`end` map to `left`/`right`
11+
* based on `direction` (`'ltr'` default, `'rtl'` mirrors).
12+
*/
13+
export function resolveBoundaryPadding(
14+
padding: PositioningBoundaryPadding | undefined,
15+
direction: 'ltr' | 'rtl',
16+
): ResolvedBoundaryPadding {
17+
if (padding === undefined) {
18+
return ZERO;
19+
}
20+
21+
if (typeof padding === 'number') {
22+
return { top: padding, right: padding, bottom: padding, left: padding };
23+
}
24+
25+
const { top = 0, end = 0, bottom = 0, start = 0 } = padding;
26+
27+
return direction === 'rtl' ? { top, right: start, bottom, left: end } : { top, right: end, bottom, left: start };
28+
}
29+
30+
export function applyBoundaryPadding(rect: DOMRect, padding: ResolvedBoundaryPadding): BoundaryRectLike {
31+
return {
32+
top: rect.top + padding.top,
33+
right: rect.right - padding.right,
34+
bottom: rect.bottom - padding.bottom,
35+
left: rect.left + padding.left,
36+
width: rect.width - padding.left - padding.right,
37+
height: rect.height - padding.top - padding.bottom,
38+
};
39+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
export { computeAvailableHeight, computeAvailableWidth } from './autoSizeBoundary';
2+
export { applyBoundaryPadding, resolveBoundaryPadding } from './boundaryPadding';
3+
export type { BoundaryRectLike } from './boundaryPadding';
24
export { applyOffset, resolveOffset } from './offset';
35
export {
46
getCoverSelfAlignment,

0 commit comments

Comments
 (0)