diff --git a/change/@fluentui-react-headless-components-preview-11a7a26b-d650-4d75-b17e-cb9d56a242fc.json b/change/@fluentui-react-headless-components-preview-11a7a26b-d650-4d75-b17e-cb9d56a242fc.json new file mode 100644 index 0000000000000..2be6c7b7c38fe --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-11a7a26b-d650-4d75-b17e-cb9d56a242fc.json @@ -0,0 +1,6 @@ +{ + "type": "patch", + "comment": "feat: add autoSize to headless usePositioning", + "packageName": "@fluentui/react-headless-components-preview", + "email": "vgenaev@gmail.com" +} diff --git a/change/@fluentui-react-positioning-eabe93ba-61eb-4d74-9975-7bbdf563ce9b.json b/change/@fluentui-react-positioning-eabe93ba-61eb-4d74-9975-7bbdf563ce9b.json new file mode 100644 index 0000000000000..b1973937323e2 --- /dev/null +++ b/change/@fluentui-react-positioning-eabe93ba-61eb-4d74-9975-7bbdf563ce9b.json @@ -0,0 +1,6 @@ +{ + "type": "minor", + "comment": "feat: expose normalizeAutosize", + "packageName": "@fluentui/react-positioning", + "email": "vgenaev@gmail.com" +} diff --git a/packages/react-components/react-headless-components-preview/library/config/tests.cjs b/packages/react-components/react-headless-components-preview/library/config/tests.cjs index 3507289757d2e..5c9031f7a8041 100644 --- a/packages/react-components/react-headless-components-preview/library/config/tests.cjs +++ b/packages/react-components/react-headless-components-preview/library/config/tests.cjs @@ -14,6 +14,17 @@ global.ResizeObserver = class ResizeObserver { // no-op for jsdom } }; + +// JSDOM provides `CSS.escape` but not `CSS.supports`, so feature detection has nothing to call. +// Provide it, defaulting to "unsupported" — which is what this environment genuinely is, and what +// the missing method already resolved to. Tests exercising a feature-gated path opt in with +// `jest.spyOn(CSS, 'supports')`. +if (typeof CSS === 'undefined') { + global.CSS = { supports: () => false }; +} else if (typeof CSS.supports !== 'function') { + CSS.supports = () => false; +} + // JSDOM does not implement native APIs yet. // Provide a minimal test shim so components using showModal/show/close can run in Jest. if (typeof HTMLDialogElement !== 'undefined') { diff --git a/packages/react-components/react-headless-components-preview/library/docs/popover-spec.md b/packages/react-components/react-headless-components-preview/library/docs/popover-spec.md index bdda419957b68..406a039e11f91 100644 --- a/packages/react-components/react-headless-components-preview/library/docs/popover-spec.md +++ b/packages/react-components/react-headless-components-preview/library/docs/popover-spec.md @@ -174,30 +174,43 @@ The headless Popover does **not** add `aria-live` to the surface. Consumers rend ## Positioning -Placement is handled entirely by the `usePositioning` hook, which writes native CSS anchor-positioning properties onto the surface element. No JS layout loop. +Placement is handled entirely by the `usePositioning` hook, which writes native CSS anchor-positioning properties onto the surface element. _Placement_ involves no JS layout loop — the browser resolves it, including flipping. _Sizing_ is the one exception: `autoSize` reads the anchor's rect to derive a maximum size. It never reads the surface's own rect, so there is still no measure/apply feedback loop. ### Options (all optional) -| Option | Type | Default | Effect | -| ------------------- | ----------------------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `position` | `'above' \| 'below' \| 'before' \| 'after'` | `'above'` | Which side of the anchor the surface sits on. Physical `top` / `bottom` / `left` / `right` are normalized. | -| `align` | `'start' \| 'center' \| 'end' \| 'top' \| 'bottom'` | `'center'` | Cross-axis alignment. `top` → `start`, `bottom` → `end` (v9 aliases). | -| `offset` | `number \| { mainAxis?: number; crossAxis?: number }` | `0` | Logical-margin offset from the anchor. | -| `fallbackPositions` | `PositioningShorthandValue[]` | `[]` | Custom fallback chain. Each entry is converted to a `` value inline in `position-try-fallbacks`. | -| `coverTarget` | `boolean` | `false` | Overlap the anchor instead of sitting beside it. | -| `pinned` | `boolean` | `false` | Disable fallback flipping; surface stays at the requested placement even if it overflows. | -| `matchTargetSize` | `'width'` | — | Sets the surface's `width` to `anchor-size(width)`. | -| `strategy` | `'fixed' \| 'absolute'` | `'absolute'` | CSS `position` property value on the surface. Matches v9's default. Use `'fixed'` when the surface needs to escape transformed / `contain: layout` ancestors for anchoring purposes. | -| `target` | `HTMLElement \| RefObject` | — | Custom anchor element. When set, `anchor-name` is written on this element instead of the trigger. | -| `positioningRef` | `Ref` | — | `{ setTarget(el): void; updatePosition(): void }`. `updatePosition` is a no-op — native positioning self-updates. | +| Option | Type | Default | Effect | +| ------------------- | --------------------------------------------------------------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `position` | `'above' \| 'below' \| 'before' \| 'after'` | `'above'` | Which side of the anchor the surface sits on. Physical `top` / `bottom` / `left` / `right` are normalized. | +| `align` | `'start' \| 'center' \| 'end' \| 'top' \| 'bottom'` | `'center'` | Cross-axis alignment. `top` → `start`, `bottom` → `end` (v9 aliases). | +| `offset` | `number \| { mainAxis?: number; crossAxis?: number }` | `0` | Logical-margin offset from the anchor. | +| `fallbackPositions` | `PositioningShorthandValue[]` | `[]` | Custom fallback chain. Each entry is converted to a `` value inline in `position-try-fallbacks`. | +| `coverTarget` | `boolean` | `false` | Overlap the anchor instead of sitting beside it. | +| `pinned` | `boolean` | `false` | Disable fallback flipping; surface stays at the requested placement even if it overflows. | +| `matchTargetSize` | `'width'` | — | Sets the surface's `width` to `anchor-size(width)`. | +| `autoSize` | `boolean \| 'height' \| 'width' \| 'always' \| 'height-always' \| 'width-always'` | — | Constrains the surface to the space available around the anchor. `true`/`'always'` constrain both axes, `'height'` the block axis, `'width'` the inline axis; the `-always` values are obsolete aliases kept for parity with `@fluentui/react-positioning`. Writes `max-block-size`/`max-inline-size` plus `overflow-block`/`overflow-inline: auto`. | +| `strategy` | `'fixed' \| 'absolute'` | `'absolute'` | CSS `position` property value on the surface. Matches v9's default. Use `'fixed'` when the surface needs to escape transformed / `contain: layout` ancestors for anchoring purposes. | +| `target` | `HTMLElement \| RefObject` | — | Custom anchor element. When set, `anchor-name` is written on this element instead of the trigger. | +| `positioningRef` | `Ref` | — | `{ setTarget(el): void; updatePosition(): void }`. `updatePosition` is a no-op — native positioning self-updates. | ### Rendering - The hook writes `anchor-name: --popover-anchor-` on the anchor (trigger or custom target) via `useIsomorphicLayoutEffect`. - On the surface it writes `position: absolute` (or `fixed` if `strategy: 'fixed'`); `inset: auto; margin: 0; position-anchor: --popover-anchor-; position-area: ; position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline`. The `inset: auto; margin: 0` reset is required because the UA popover stylesheet sets `inset: 0; margin: auto`, which fights `position-area`. -- For center alignment, the hook also writes `place-self: anchor-center` as a workaround for https://crbug.com/438334710 (Chromium <=130 doesn't reliably apply the implicit anchor-center self-alignment to single-keyword `position-area` values). +- For center alignment, the hook writes `anchor-center` as a workaround for https://crbug.com/438334710 (Chromium <=130 doesn't reliably apply the implicit anchor-center self-alignment to single-keyword `position-area` values). It applies it to the **cross axis only** — `justify-self` for `above`/`below`, `align-self` for `before`/`after`. The `place-self` shorthand would set both axes, which centres the surface on its anchor along the main axis too, overriding `position-area` and dropping the surface on top of the anchor. +- With `autoSize`, the hook additionally writes `max-block-size`/`max-inline-size` and `overflow-block`/`overflow-inline: auto` on the constrained axes. An overflow value the consumer already set inline is never overwritten, and never removed on teardown. - `data-placement` is set to the requested placement and then live-updated by `usePlacementObserver` (ResizeObserver + scroll listener) to reflect the browser's post-flip decision. +### autoSize limitations + +- **Set `box-sizing: border-box` on the surface.** `max-block-size`/`max-inline-size` constrain the content box, and the UA stylesheet for `[popover]` adds a border and padding — so a `content-box` surface overflows the constraint by roughly 10px. Same requirement as `matchTargetSize`. +- **The boundary is the viewport.** There is no `overflowBoundary` equivalent, so a surface inside a scrollable or clipping ancestor may be given more room than it can actually use. +- **The constraint is the roomier of the requested side and its opposite, not the resolved one.** It is derived from the anchor rather than the browser's flip decision, which is what lets the browser still choose the roomier side — the surface is never pre-shrunk onto the side it was asked for. A CSS-only constraint (`max-block-size: 100%`) resolves per placement and so defeats flipping entirely. +- **Custom `fallbackPositions` do not widen the measurement.** Only the requested side and its opposite are measured, so a fallback chain that crosses axes (for example `below` falling back to `after`) is constrained more tightly than it strictly needs to be. +- **Only the main axis is measured.** The cross axis is bounded by the viewport: under `position-area` an auto-sized surface already shrink-to-fits within its area, so a tighter cross-axis value would only affect a surface with an explicit size. +- **No `shift` equivalent.** For `before`/`after` with `align: 'center'` the surface stays centred on its anchor, so near a boundary edge it can still spill symmetrically even when constrained. CSS anchor positioning has no counterpart to Floating UI's `shift`. +- **Horizontal writing modes only.** The properties written are logical, and measuring the roomier of two opposite sides is direction-invariant, so RTL is unaffected. The computation does assume `horizontal-tb` when mapping physical edges onto logical axes, matching the assumption already made by the `data-placement` read-back. In a vertical writing mode the block and inline measurements are transposed. +- **An anchor scrolled out of view takes the surface with it.** The surface stays anchored, so it leaves the viewport alongside its anchor; the constraint is capped at the boundary size but does not pull the surface back into view. + ### Arrow Arrow positioning is **consumer-owned CSS** keyed off `[data-placement]`. The hook doesn't manipulate the arrow element. Consumers writing arrow styles typically target `[data-placement^='above']`, `[data-placement^='below']`, etc., and use anchor queries (`@container anchored()`) for flip-aware styling when supported. 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 8125ce4176703..07cf8403042d3 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 @@ -28,7 +28,7 @@ export { Position } export { PositioningImperativeRef } // @public (undocumented) -export type PositioningProps = Pick; +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 1628efeaf2c91..71a1c0a2fa4fb 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 @@ -453,6 +453,7 @@ describe('positioning observer', () => { @@ -596,3 +597,122 @@ describe('positioning observer', () => { }); } }); + +if (typeof window !== 'undefined' && window.CSS?.supports?.('anchor-name: --x')) { + describe('positioning autoSize', () => { + const surfaceSelector = popoverContentSelector; + + const TallContent = () => ( +
+ {Array.from({ length: 60 }, (_, index) => ( +
{`item ${index}`}
+ ))} +
+ ); + + /** Anchors the trigger `topOffset` px down a 600px-tall viewport. */ + const Fixture = ({ topOffset, positioning }: { topOffset: number; positioning?: PopoverProps['positioning'] }) => ( +
+ + + + + + + + +
+ ); + + it('keeps an oversized surface inside the viewport', () => { + cy.viewport(1000, 600); + mount(); + + cy.get(surfaceSelector).should($surface => { + const rect = $surface[0].getBoundingClientRect(); + expect(rect.top).to.be.at.least(-1); + expect(rect.bottom).to.be.at.most(601); + }); + }); + + it('settles on the roomier side rather than being shrunk onto the requested one', () => { + cy.viewport(1000, 600); + mount(); + + cy.get(surfaceSelector).should($surface => { + expect($surface[0].getAttribute('data-placement')).to.match(/^above/); + }); + cy.get(surfaceSelector).should($surface => { + expect($surface[0].getBoundingClientRect().height).to.be.greaterThan(200); + }); + }); + + it('applies the constraint before the surface is first painted', () => { + cy.viewport(1000, 600); + + const heights: number[] = []; + + cy.window().then(win => { + const observer = new win.ResizeObserver(entries => { + entries.forEach(entry => heights.push(entry.contentRect.height)); + }); + cy.get('body').then($body => { + const target = $body[0]; + new win.MutationObserver((_records, self) => { + const surface = target.querySelector(surfaceSelector); + if (surface) { + observer.observe(surface); + self.disconnect(); + } + }).observe(target, { childList: true, subtree: true }); + }); + }); + + mount(); + + cy.get(surfaceSelector).should('be.visible'); + cy.then(() => { + heights.forEach(height => expect(height).to.be.lessThan(1200)); + }); + }); + + it('constrains a surface that covers its target', () => { + cy.viewport(1000, 600); + mount(); + + cy.get(surfaceSelector).should($surface => { + expect($surface[0].getBoundingClientRect().bottom).to.be.at.most(601); + }); + }); + + it('recalculates when the viewport is resized', () => { + cy.viewport(1000, 600); + mount(); + + cy.get(surfaceSelector).should('be.visible'); + cy.viewport(1000, 400); + + cy.get(surfaceSelector).should($surface => { + expect($surface[0].getBoundingClientRect().bottom).to.be.at.most(401); + }); + }); + + it('recalculates when the anchor scrolls toward a viewport edge', () => { + cy.viewport(1000, 600); + mount( +
+ +
, + ); + + cy.get(surfaceSelector).should('be.visible'); + cy.window().then(win => win.scrollTo(0, 200)); + + cy.get(surfaceSelector).should($surface => { + const rect = $surface[0].getBoundingClientRect(); + expect(rect.top).to.be.at.least(-1); + expect(rect.bottom).to.be.at.most(601); + }); + }); + }); +} diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/types.test.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/types.test.ts new file mode 100644 index 0000000000000..8345d5ef3a115 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/types.test.ts @@ -0,0 +1,20 @@ +import type { AutoSize } from '@fluentui/react-positioning'; +import type { PositioningProps } from './types'; + +describe('PositioningProps', () => { + it('accepts every autoSize value the canonical positioning package accepts', () => { + // Migrating consumers pass their existing `positioning` object through unchanged, so the + // headless surface must not reject any value `@fluentui/react-positioning` accepts — including + // the obsolete `-always` aliases. + const values: AutoSize[] = [true, false, 'always', 'height', 'height-always', 'width', 'width-always']; + const props: PositioningProps[] = values.map(autoSize => ({ autoSize })); + + expect(props).toHaveLength(values.length); + }); + + it('leaves autoSize optional', () => { + const props: PositioningProps = { position: 'below' }; + + expect(props.autoSize).toBeUndefined(); + }); +}); 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 3f92a6d60b922..da7ac9c7517f8 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 @@ -14,6 +14,7 @@ export type PositioningReturn = { export type PositioningProps = Pick< CanonicalPositioningProps, | 'align' + | 'autoSize' | 'coverTarget' | 'fallbackPositions' | 'matchTargetSize' diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useAutoSize.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useAutoSize.ts new file mode 100644 index 0000000000000..ff7fd5f4c5a87 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useAutoSize.ts @@ -0,0 +1,149 @@ +'use client'; + +import * as React from 'react'; +import { useEventCallback, useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; +import { computeAvailableSize, getBoundarySize } from './utils'; +import type { NormalizedAutoSize } from '@fluentui/react-positioning'; +import type { AvailableSizeOptions } from './utils'; + +const MAX_BLOCK_SIZE = 'max-block-size'; +const MAX_INLINE_SIZE = 'max-inline-size'; +const OVERFLOW_BLOCK = 'overflow-block'; +const OVERFLOW_INLINE = 'overflow-inline'; + +interface OverflowOwnership { + block: boolean; + inline: boolean; +} + +export interface UseAutoSizeOptions extends AvailableSizeOptions { + containerEl: HTMLElement | null; + targetEl: HTMLElement | null; + targetDocument: Document | undefined; + autoSize: NormalizedAutoSize | false; +} + +function supportsAnchorPositioning(targetDocument: Document | undefined): boolean { + return Boolean(targetDocument?.defaultView?.CSS?.supports?.('anchor-name', '--x')); +} + +function setIfChanged(element: HTMLElement, property: string, value: string): void { + if (element.style.getPropertyValue(property) !== value) { + element.style.setProperty(property, value); + } +} + +/** + * Applies (or removes) the constraint for one axis. + * + * Overflow is only written when the consumer has not already set it inline, and only removed again + * when this hook was the one that wrote it — a consumer's own value must survive teardown. + */ +function applyAxis( + element: HTMLElement, + enabled: boolean, + size: number, + maxProperty: string, + overflowProperty: string, + owned: boolean, +): boolean { + if (!enabled || size <= 0) { + element.style.removeProperty(maxProperty); + + if (owned) { + element.style.removeProperty(overflowProperty); + } + + return false; + } + + setIfChanged(element, maxProperty, `${size}px`); + + if (!owned && !element.style.getPropertyValue(overflowProperty)) { + element.style.setProperty(overflowProperty, 'auto'); + return true; + } + + return owned; +} + +export function useAutoSize(options: UseAutoSizeOptions): () => void { + const { containerEl, targetEl, targetDocument, autoSize, ...sizeOptions } = options; + const { position, offset, pinned, coverTarget } = sizeOptions; + + const ownershipRef = React.useRef({ block: false, inline: false }); + + const apply = useEventCallback(() => { + if (!containerEl) { + return; + } + + const boundary = getBoundarySize(targetDocument); + + const enabled = [autoSize, targetEl, boundary].every(Boolean) && supportsAnchorPositioning(targetDocument); + + const available = + enabled && targetEl && boundary + ? computeAvailableSize(targetEl.getBoundingClientRect(), boundary, sizeOptions) + : { blockSize: 0, inlineSize: 0 }; + + const ownership = ownershipRef.current; + + ownership.block = applyAxis( + containerEl, + enabled && autoSize !== false && autoSize.applyMaxHeight, + available.blockSize, + MAX_BLOCK_SIZE, + OVERFLOW_BLOCK, + ownership.block, + ); + + ownership.inline = applyAxis( + containerEl, + enabled && autoSize !== false && autoSize.applyMaxWidth, + available.inlineSize, + MAX_INLINE_SIZE, + OVERFLOW_INLINE, + ownership.inline, + ); + }); + + useIsomorphicLayoutEffect(() => { + const ownership = ownershipRef.current; + + apply(); + + return () => { + if (!containerEl) { + return; + } + + containerEl.style.removeProperty(MAX_BLOCK_SIZE); + containerEl.style.removeProperty(MAX_INLINE_SIZE); + + if (ownership.block) { + containerEl.style.removeProperty(OVERFLOW_BLOCK); + } + + if (ownership.inline) { + containerEl.style.removeProperty(OVERFLOW_INLINE); + } + + ownership.block = false; + ownership.inline = false; + }; + }, [ + apply, + containerEl, + targetEl, + targetDocument, + autoSize, + position, + offset.mainAxis, + offset.crossAxis, + pinned, + coverTarget, + ]); + + return apply; +} 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 c58fed88c5056..0bae6007e2d3b 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 @@ -10,18 +10,25 @@ import { computePosition, debounce } from './utils'; * or content reflow — consumers can style the surface (arrows, animations) * via `[data-placement^="above"]` and friends and stay in sync. * + * @param onUpdate - Invoked on every tick, before the placement read-back. Lets other positioning + * concerns reuse this observer's listeners instead of registering their own. Called before the + * read-back bails so that it still runs when the surface overlaps its anchor and no placement can + * be detected. */ export function usePlacementObserver( containerEl: HTMLElement | null, targetEl: HTMLElement | null, targetDocument: Document | undefined, disabled = false, + onUpdate?: () => void, ): () => void { const update = useEventCallback(() => { if (!containerEl || !targetEl) { return; } + onUpdate?.(); + const result = computePosition(targetEl, containerEl); if (!result) { return; 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 cadfd8afc62d6..51a16712bb3f2 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 @@ -165,16 +165,27 @@ describe('usePositioning', () => { expect(node).toHaveStyle({ positionArea: 'center', alignSelf: 'end', justifySelf: 'start' }); }); - it('containerRef writes place-self: anchor-center for center alignment (crbug 438334710 workaround)', () => { - const result = mountHook({ position: 'above', align: 'center' }); - const node = document.createElement('div'); - - act(() => { - result.current.containerRef(node); - }); + it.each` + position | centred | untouched + ${'above'} | ${'justify-self'} | ${'align-self'} + ${'below'} | ${'justify-self'} | ${'align-self'} + ${'before'} | ${'align-self'} | ${'justify-self'} + ${'after'} | ${'align-self'} | ${'justify-self'} + `( + 'containerRef centres $position only on the cross axis via $centred (crbug 438334710 workaround)', + ({ position, centred, untouched }) => { + const result = mountHook({ position, align: 'center' }); + const node = document.createElement('div'); + + act(() => { + result.current.containerRef(node); + }); - expect(node).toHaveStyle({ placeSelf: 'anchor-center' }); - }); + expect(node.style.getPropertyValue('place-self')).toBe(''); + expect(node.style.getPropertyValue(centred)).toBe('anchor-center'); + expect(node.style.getPropertyValue(untouched)).toBe(''); + }, + ); it('containerRef does not write place-self for non-center alignments', () => { const result = mountHook({ position: 'above', align: 'start' }); @@ -232,6 +243,247 @@ describe('usePositioning', () => { }); }); +describe('usePositioning autoSize', () => { + const VIEWPORT_HEIGHT = 700; + const VIEWPORT_WIDTH = 1000; + + function stubViewport(width = VIEWPORT_WIDTH, height = VIEWPORT_HEIGHT) { + Object.defineProperty(document.documentElement, 'clientWidth', { configurable: true, value: width }); + Object.defineProperty(document.documentElement, 'clientHeight', { configurable: true, value: height }); + } + + function stubAnchorSupport(supported: boolean) { + jest.spyOn(CSS, 'supports').mockReturnValue(supported); + } + + function makeAnchor(rect: Partial = {}) { + const { top = 560, left = 100, width = 200, height = 40 } = rect; + const node = document.createElement('div'); + let current = { top, left, width, height }; + + node.getBoundingClientRect = () => + ({ + top: current.top, + left: current.left, + right: current.left + current.width, + bottom: current.top + current.height, + width: current.width, + height: current.height, + x: current.left, + y: current.top, + toJSON: () => ({}), + } as DOMRect); + + return { + node, + moveTo(next: Partial) { + current = { ...current, ...next }; + }, + }; + } + + function mount(options: PositioningProps) { + const captured = { current: null as PositioningReturn | null }; + const Capture = (props: { options: PositioningProps }) => { + captured.current = usePositioning(props.options); + return null; + }; + const view = render(); + + return { + attach(anchor: HTMLElement, surface: HTMLElement) { + act(() => { + captured.current?.targetRef(anchor); + captured.current?.containerRef(surface); + }); + }, + rerender(next: PositioningProps) { + act(() => { + view.rerender(); + }); + }, + unmount: view.unmount, + }; + } + + beforeEach(() => { + stubViewport(); + stubAnchorSupport(true); + }); + + afterEach(() => { + jest.restoreAllMocks(); + const documentElement = document.documentElement as unknown as Record; + delete documentElement.clientWidth; + delete documentElement.clientHeight; + }); + + it('constrains both axes when enabled', () => { + const anchor = makeAnchor(); + const surface = document.createElement('div'); + + mount({ autoSize: true, position: 'below' }).attach(anchor.node, surface); + + expect(surface.style.getPropertyValue('max-block-size')).toBe('560px'); + expect(surface.style.getPropertyValue('max-inline-size')).toBe(`${VIEWPORT_WIDTH}px`); + expect(surface.style.getPropertyValue('overflow-block')).toBe('auto'); + expect(surface.style.getPropertyValue('overflow-inline')).toBe('auto'); + }); + + it.each` + autoSize | blockSize | inlineSize + ${'height'} | ${'560px'} | ${''} + ${'width'} | ${''} | ${'1000px'} + `('constrains only the requested axis for autoSize: $autoSize', ({ autoSize, blockSize, inlineSize }) => { + const anchor = makeAnchor(); + const surface = document.createElement('div'); + + mount({ autoSize, position: 'below' }).attach(anchor.node, surface); + + expect(surface.style.getPropertyValue('max-block-size')).toBe(blockSize); + expect(surface.style.getPropertyValue('max-inline-size')).toBe(inlineSize); + }); + + it('writes nothing when autoSize is absent', () => { + const anchor = makeAnchor(); + const surface = document.createElement('div'); + + mount({ position: 'below' }).attach(anchor.node, surface); + + expect(surface.style.getPropertyValue('max-block-size')).toBe(''); + expect(surface.style.getPropertyValue('max-inline-size')).toBe(''); + expect(surface.style.getPropertyValue('overflow-block')).toBe(''); + expect(surface.style.getPropertyValue('overflow-inline')).toBe(''); + }); + + it('writes nothing when the engine lacks CSS anchor positioning', () => { + stubAnchorSupport(false); + const anchor = makeAnchor(); + const surface = document.createElement('div'); + + expect(() => mount({ autoSize: true }).attach(anchor.node, surface)).not.toThrow(); + + expect(surface.style.getPropertyValue('max-block-size')).toBe(''); + expect(surface.style.getPropertyValue('overflow-block')).toBe(''); + }); + + it('removes what it wrote when autoSize is turned off', () => { + const anchor = makeAnchor(); + const surface = document.createElement('div'); + const view = mount({ autoSize: true, position: 'below' }); + + view.attach(anchor.node, surface); + expect(surface.style.getPropertyValue('max-block-size')).toBe('560px'); + + view.rerender({ autoSize: false, position: 'below' }); + + expect(surface.style.getPropertyValue('max-block-size')).toBe(''); + expect(surface.style.getPropertyValue('max-inline-size')).toBe(''); + expect(surface.style.getPropertyValue('overflow-block')).toBe(''); + expect(surface.style.getPropertyValue('overflow-inline')).toBe(''); + }); + + it('leaves no residue on the surface after unmount', () => { + const anchor = makeAnchor(); + const surface = document.createElement('div'); + const view = mount({ autoSize: true, position: 'below' }); + + view.attach(anchor.node, surface); + view.unmount(); + + expect(surface.style.getPropertyValue('max-block-size')).toBe(''); + expect(surface.style.getPropertyValue('max-inline-size')).toBe(''); + expect(surface.style.getPropertyValue('overflow-block')).toBe(''); + expect(surface.style.getPropertyValue('overflow-inline')).toBe(''); + }); + + it('never overwrites or removes a consumer-set overflow', () => { + const anchor = makeAnchor(); + const surface = document.createElement('div'); + surface.style.setProperty('overflow-block', 'hidden'); + const view = mount({ autoSize: true, position: 'below' }); + + view.attach(anchor.node, surface); + expect(surface.style.getPropertyValue('overflow-block')).toBe('hidden'); + + view.unmount(); + expect(surface.style.getPropertyValue('overflow-block')).toBe('hidden'); + }); + + it('is still driven by the observer tick when covering the target hides the resolved placement', async () => { + const anchor = makeAnchor(); + const surface = document.createElement('div'); + + // Covering overlaps the surface and its anchor, so the placement read-back resolves nothing and + // bails. The tick has to reach autoSize before that bail, or the constraint freezes. Recalculation + // on scroll in general is covered in the browser; this pins the ordering the bail depends on. + mount({ autoSize: true, position: 'below', coverTarget: true }).attach(anchor.node, surface); + expect(surface.getAttribute('data-placement')).toBe('below'); + expect(surface.style.getPropertyValue('max-block-size')).toBe('140px'); + + anchor.moveTo({ top: 650 }); + await act(async () => { + window.dispatchEvent(new Event('scroll')); + }); + + expect(surface.style.getPropertyValue('max-block-size')).toBe('50px'); + }); + + it('leaves every pre-existing positioning write untouched when autoSize is absent', () => { + const anchor = makeAnchor(); + const surface = document.createElement('div'); + const options: PositioningProps = { + position: 'below', + align: 'start', + offset: { mainAxis: 8, crossAxis: 4 }, + fallbackPositions: ['above', 'after'], + }; + + mount(options).attach(anchor.node, surface); + + const written = Object.fromEntries( + [ + 'position', + 'inset', + 'margin', + 'margin-block-start', + 'margin-block-end', + 'margin-inline-start', + 'margin-inline-end', + 'position-area', + 'position-try-fallbacks', + ].map(property => [property, surface.style.getPropertyValue(property)]), + ); + + expect(written).toEqual({ + position: 'fixed', + inset: 'auto', + margin: '0px', + 'margin-block-start': '8px', + 'margin-block-end': '8px', + 'margin-inline-start': '4px', + 'margin-inline-end': '4px', + 'position-area': 'block-end span-inline-end', + 'position-try-fallbacks': 'block-start, inline-end', + }); + expect(surface.style.getPropertyValue('position-anchor')).toMatch(/^--popover-anchor-/); + expect(surface).toHaveAttribute('data-placement', 'below-start'); + }); + + it('registers no listeners beyond those the placement observer already uses', () => { + const anchor = makeAnchor(); + const countListeners = (options: PositioningProps) => { + const spy = jest.spyOn(window, 'addEventListener'); + mount(options).attach(anchor.node, document.createElement('div')); + const count = spy.mock.calls.filter(([type]) => type === 'scroll' || type === 'resize').length; + spy.mockRestore(); + return count; + }; + + expect(countListeners({ autoSize: true, position: 'below' })).toBe(countListeners({ position: 'below' })); + }); +}); + describe('getPlacementString', () => { it('returns the bare position for center alignment', () => { expect(getPlacementString('above', 'center')).toBe('above'); 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 e577ae1f96993..578752a53ac02 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 @@ -3,6 +3,7 @@ import * as React from 'react'; import { useId, useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; +import { normalizeAutoSize } from '@fluentui/react-positioning'; import type { PositioningImperativeRef, PositioningShorthandValue, @@ -12,6 +13,7 @@ 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'; +import { useAutoSize } from './useAutoSize'; import { usePlacementObserver } from './usePlacementObserver'; export type TargetElement = HTMLElement | PositioningVirtualElement; @@ -44,11 +46,13 @@ export function usePositioning(options: PositioningProps): PositioningReturn { strategy = 'fixed', matchTargetSize, positioningRef, + autoSize, } = options; const align = normalizeAlign(alignInput); - const { mainAxis, crossAxis } = resolveOffset(offset); + const resolvedOffset = resolveOffset(offset); + const { mainAxis, crossAxis } = resolvedOffset; const coverAlignment = React.useMemo( () => (coverTarget ? getCoverSelfAlignment(position, align) : null), [coverTarget, position, align], @@ -67,7 +71,26 @@ export function usePositioning(options: PositioningProps): PositioningReturn { const fallbackAreas = React.useMemo(() => fallbackPositions.map(shorthandToPositionArea), [fallbackPositions]); - const requestPlacementUpdate = usePlacementObserver(containerEl, effectiveTarget, targetDocument, coverTarget); + const normalizedAutoSize = React.useMemo(() => normalizeAutoSize(autoSize), [autoSize]); + + const applyAutoSize = useAutoSize({ + containerEl, + targetEl: effectiveTarget, + targetDocument, + autoSize: normalizedAutoSize, + position, + offset: resolvedOffset, + pinned: Boolean(pinned), + coverTarget, + }); + + const requestPlacementUpdate = usePlacementObserver( + containerEl, + effectiveTarget, + targetDocument, + coverTarget && !normalizedAutoSize, + applyAutoSize, + ); React.useImperativeHandle( positioningRef, @@ -150,12 +173,16 @@ export function usePositioning(options: PositioningProps): PositioningReturn { * Workaround for https://crbug.com/438334710: Chromium (<=130-ish) doesn't apply the implicit `anchor-center` self-alignment that the spec defines for single-keyword `position-area` values (`block-start`, `block-end`, - ` inline-start`, `inline-end`) or `span-all`. + `inline-start`, `inline-end`) or `span-all`. */ + node.style.removeProperty('place-self'); + if (align === ALIGNMENTS.center) { - node.style.setProperty('place-self', 'anchor-center'); + const isBlockAxisMain = position === POSITIONS.above || position === POSITIONS.below; + + node.style.setProperty(isBlockAxisMain ? 'justify-self' : 'align-self', 'anchor-center'); + node.style.removeProperty(isBlockAxisMain ? 'align-self' : 'justify-self'); } else { - node.style.removeProperty('place-self'); node.style.removeProperty('align-self'); node.style.removeProperty('justify-self'); } diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/computeAvailableSize.test.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/computeAvailableSize.test.ts new file mode 100644 index 0000000000000..212b9639f7409 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/computeAvailableSize.test.ts @@ -0,0 +1,146 @@ +import { computeAvailableSize, getBoundarySize } from './computeAvailableSize'; +import type { AvailableSizeOptions, BoundarySize, EdgeRect } from './computeAvailableSize'; + +function makeRect(rect: Partial & { width?: number; height?: number }): EdgeRect { + const { top = 0, left = 0, width = 0, height = 0 } = rect; + return { + top, + left, + right: rect.right ?? left + width, + bottom: rect.bottom ?? top + height, + }; +} + +const boundary: BoundarySize = { width: 1000, height: 700 }; + +function options(overrides: Partial = {}): AvailableSizeOptions { + return { + position: 'above', + offset: { mainAxis: 0, crossAxis: 0 }, + pinned: false, + coverTarget: false, + ...overrides, + }; +} + +describe('computeAvailableSize', () => { + describe('reachable sides', () => { + it('takes the roomiest side the surface can reach, not the requested one', () => { + const anchor = makeRect({ top: 560, height: 40, left: 100, width: 200 }); + + const { blockSize } = computeAvailableSize(anchor, boundary, options({ position: 'below' })); + + expect(blockSize).toBe(560); + }); + + it('is the same whichever of the two sides is requested', () => { + const anchor = makeRect({ top: 560, height: 40, left: 100, width: 200 }); + + const requestedBelow = computeAvailableSize(anchor, boundary, options({ position: 'below' })); + const requestedAbove = computeAvailableSize(anchor, boundary, options({ position: 'above' })); + + expect(requestedBelow.blockSize).toBe(requestedAbove.blockSize); + }); + + it('measures only the requested side when pinned, because nothing can flip', () => { + const anchor = makeRect({ top: 560, height: 40, left: 100, width: 200 }); + + const { blockSize } = computeAvailableSize(anchor, boundary, options({ position: 'below', pinned: true })); + + expect(blockSize).toBe(100); + }); + + it('resolves inline placements on the inline axis', () => { + const anchor = makeRect({ top: 200, height: 40, left: 300, width: 200 }); + + const { inlineSize } = computeAvailableSize(anchor, boundary, options({ position: 'before' })); + + expect(inlineSize).toBe(500); + }); + }); + + describe('covering the target', () => { + it.each` + position | mainExtent + ${'above'} | ${40} + ${'below'} | ${40} + ${'before'} | ${200} + ${'after'} | ${200} + `('measures $position from the anchor far edge, gaining its extent', ({ position, mainExtent }) => { + const anchor = makeRect({ top: 200, height: 40, left: 300, width: 200 }); + const isBlockMain = position === 'above' || position === 'below'; + + const covering = computeAvailableSize(anchor, boundary, options({ position, coverTarget: true })); + const beside = computeAvailableSize(anchor, boundary, options({ position, pinned: true })); + + const measured = isBlockMain ? covering.blockSize - beside.blockSize : covering.inlineSize - beside.inlineSize; + + expect(measured).toBe(mainExtent); + }); + }); + + describe('bounds', () => { + it('never exceeds the boundary when the anchor has scrolled out of view', () => { + const anchor = makeRect({ top: -260, height: 21, left: 100, width: 200 }); + + const { blockSize } = computeAvailableSize(anchor, boundary, options({ position: 'below', pinned: true })); + + expect(blockSize).toBeLessThanOrEqual(boundary.height); + }); + + it('never returns a negative size', () => { + const anchor = makeRect({ top: -200, height: 1200, left: -50, width: 1200 }); + + const { blockSize, inlineSize } = computeAvailableSize(anchor, boundary, options({ pinned: true })); + + expect(blockSize).toBeGreaterThanOrEqual(0); + expect(inlineSize).toBeGreaterThanOrEqual(0); + }); + + it('bounds the cross axis by the boundary rather than by the anchor', () => { + const anchor = makeRect({ top: 200, height: 40, left: 300, width: 200 }); + + const { inlineSize } = computeAvailableSize(anchor, boundary, options({ position: 'above' })); + + expect(inlineSize).toBe(boundary.width); + }); + }); + + describe('offsets', () => { + const anchor = makeRect({ top: 200, height: 40, left: 300, width: 200 }); + + it('subtracts the main axis offset from both margins', () => { + const without = computeAvailableSize(anchor, boundary, options({ pinned: true })); + const withOffset = computeAvailableSize( + anchor, + boundary, + options({ pinned: true, offset: { mainAxis: 8, crossAxis: 0 } }), + ); + + expect(without.blockSize - withOffset.blockSize).toBe(16); + }); + + it('subtracts the cross axis offset from both margins', () => { + const without = computeAvailableSize(anchor, boundary, options({ pinned: true })); + const withOffset = computeAvailableSize( + anchor, + boundary, + options({ pinned: true, offset: { mainAxis: 0, crossAxis: 5 } }), + ); + + expect(without.inlineSize - withOffset.inlineSize).toBe(10); + }); + }); +}); + +describe('getBoundarySize', () => { + it('reads the document element so scrollbars are excluded', () => { + const targetDocument = { documentElement: { clientWidth: 1024, clientHeight: 768 } } as Document; + + expect(getBoundarySize(targetDocument)).toEqual({ width: 1024, height: 768 }); + }); + + it('returns undefined when there is no document to measure', () => { + expect(getBoundarySize(undefined)).toBeUndefined(); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/computeAvailableSize.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/computeAvailableSize.ts new file mode 100644 index 0000000000000..98c09ff38f5d1 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/computeAvailableSize.ts @@ -0,0 +1,72 @@ +import type { OffsetObject, Position, PositioningRect } from '@fluentui/react-positioning'; +import type { PositioningProps } from '../types'; +import { POSITIONS } from '../constants'; + +export type EdgeRect = Pick; +export type BoundarySize = Pick; +export type AvailableSizeOptions = Required> & { + offset: Required; +}; + +export interface AvailableSize { + blockSize: number; + inlineSize: number; +} + +const OPPOSITE_POSITION: Record = { + above: POSITIONS.below, + below: POSITIONS.above, + before: POSITIONS.after, + after: POSITIONS.before, +}; + +const isBlockAxisMain = (position: Position): boolean => position === POSITIONS.above || position === POSITIONS.below; + +const withoutOffset = (space: number, offset: number, extent: number) => + Math.max(0, Math.min(space - 2 * offset, extent)); + +export function getBoundarySize(targetDocument: Document | undefined): BoundarySize | undefined { + const documentElement = targetDocument?.documentElement; + + if (!documentElement) { + return undefined; + } + + return { width: documentElement.clientWidth, height: documentElement.clientHeight }; +} + +export function computeAvailableSize( + anchorRect: EdgeRect, + boundary: BoundarySize, + options: AvailableSizeOptions, +): AvailableSize { + const { position, pinned, coverTarget, offset } = options; + + const beside: Record = { + above: anchorRect.top, + below: boundary.height - anchorRect.bottom, + before: anchorRect.left, + after: boundary.width - anchorRect.right, + }; + + const overlapping: Record = { + above: anchorRect.bottom, + below: boundary.height - anchorRect.top, + before: anchorRect.right, + after: boundary.width - anchorRect.left, + }; + + const space = coverTarget ? overlapping : beside; + + const main = pinned || coverTarget ? space[position] : Math.max(space[position], space[OPPOSITE_POSITION[position]]); + + const isBlockMain = isBlockAxisMain(position); + const mainSize = withoutOffset(main, offset.mainAxis, isBlockMain ? boundary.height : boundary.width); + const crossSize = withoutOffset( + isBlockMain ? boundary.width : boundary.height, + offset.crossAxis, + isBlockMain ? boundary.width : boundary.height, + ); + + return isBlockMain ? { blockSize: mainSize, inlineSize: crossSize } : { blockSize: crossSize, inlineSize: mainSize }; +} 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 8506fa430c612..e3ecb7c8cb128 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,3 +1,5 @@ +export { computeAvailableSize, getBoundarySize } from './computeAvailableSize'; +export type { AvailableSize, AvailableSizeOptions, BoundarySize, EdgeRect } from './computeAvailableSize'; export { computePosition } from './computePosition'; export type { ComputePositionConfig, ComputePositionReturn } from './computePosition'; export { debounce } from './debounce'; 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 940956f736d98..62d51e4e5bb63 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 @@ -1,4 +1,4 @@ -import type { Position, PositioningProps } from '@fluentui/react-positioning'; +import type { OffsetObject, Position, PositioningProps } from '@fluentui/react-positioning'; import { POSITIONS } from '../constants'; export function applyOffset(node: HTMLElement, position: Position, mainAxis: number, crossAxis: number): void { @@ -33,7 +33,7 @@ export function applyOffset(node: HTMLElement, position: Position, mainAxis: num * positioning — rect information is not available ahead of layout — and * resolves to a zero offset. */ -export function resolveOffset(offset: PositioningProps['offset']): { mainAxis: number; crossAxis: number } { +export function resolveOffset(offset: PositioningProps['offset']): Required { if (typeof offset === 'number') { return { mainAxis: offset, crossAxis: 0 }; } diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningAutoSize.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningAutoSize.stories.tsx new file mode 100644 index 0000000000000..12fac32acd0f0 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningAutoSize.stories.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; +import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover'; + +import descriptionMd from './PositioningAutoSizeDescription.md'; +import styles from './positioning.module.css'; + +const items = Array.from({ length: 40 }, (_, index) => `Item ${index + 1}`); + +export const AutoSize = (): React.ReactNode => ( +
+ + + + + + {items.map(item => ( +
+ {item} +
+ ))} +
+
+
+); + +AutoSize.parameters = { + docs: { + description: { + story: descriptionMd, + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningAutoSizeDescription.md b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningAutoSizeDescription.md new file mode 100644 index 0000000000000..0cc4188a79411 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningAutoSizeDescription.md @@ -0,0 +1,7 @@ +`autoSize` constrains the surface to the space available around its anchor, so a surface with more content than the viewport can hold scrolls instead of being clipped. + +The constraint is derived from the anchor and the viewport — never from the surface's own size. That is what lets the browser still flip to the roomier side: the surface is never pre-shrunk onto the side it was asked for. + +Use `true` (or `'always'`) for both axes, `'height'` for the block axis, `'width'` for the inline axis. + +Give the surface `box-sizing: border-box`. `max-block-size` constrains the content box, and the UA stylesheet for `[popover]` adds a border and padding on top of it. diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/index.stories.tsx index 498fe9f2b21cd..7c263a023eb46 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/index.stories.tsx @@ -8,6 +8,7 @@ export { ShorthandPositions } from './PositioningShorthandPositions.stories'; export { Offset } from './PositioningOffset.stories'; export { CoverTarget } from './PositioningCoverTarget.stories'; export { MatchTargetSize } from './PositioningMatchTargetSize.stories'; +export { AutoSize } from './PositioningAutoSize.stories'; export { FallbackPositions } from './PositioningFallbackPositions.stories'; export { FlippingBlock } from './PositioningFlippingBlock.stories'; export { FlippingInline } from './PositioningFlippingInline.stories'; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/positioning.module.css b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/positioning.module.css index f58e3bd8b437b..ab56833fe6bfb 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/positioning.module.css +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/positioning.module.css @@ -338,3 +338,22 @@ font-size: 11px; color: var(--text-muted); } + +.surfaceAutoSize { + display: block; + width: 260px; + background: var(--bg-elev); + border-radius: var(--radius-md); + border: var(--stroke-thin) solid var(--border); + box-shadow: var(--shadow-3); + padding: var(--space-4); + /* Required: max-block-size constrains the content box, and the UA [popover] stylesheet adds a + border and padding on top of it. */ + box-sizing: border-box; + font-size: 13px; +} + +.autoSizeItem { + padding: var(--space-2) 0; + border-bottom: var(--stroke-thin) solid var(--border); +} diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/utils.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/utils.stories.tsx index 9e438478da05e..5d4de4f24f8b5 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/utils.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/utils.stories.tsx @@ -3,13 +3,12 @@ import type { PositioningProps } from '@fluentui/react-headless-components-previ /** * Subset of `PositioningProps` that the headless preview's `usePositioning` actually consumes. - * Props not destructured by the hook (e.g. `autoSize`, `flipBoundary`, `useTransform`) are excluded - * so Storybook's auto-generated args table only advertises what is supported. + * Props not destructured by the hook (e.g. `flipBoundary`, `useTransform`) are excluded so + * Storybook's auto-generated args table only advertises what is supported. */ type SupportedPositioningProps = Omit< PositioningProps, | 'arrowPadding' - | 'autoSize' | 'disableUpdateOnResize' | 'flipBoundary' | 'onPositioningEnd' diff --git a/packages/react-components/react-positioning/library/etc/react-positioning.api.md b/packages/react-components/react-positioning/library/etc/react-positioning.api.md index 9a2e65951c1e1..de491d440e930 100644 --- a/packages/react-components/react-positioning/library/etc/react-positioning.api.md +++ b/packages/react-components/react-positioning/library/etc/react-positioning.api.md @@ -41,6 +41,15 @@ export function createVirtualElementFromClick(nativeEvent: MouseEvent): Position // @internal export function mergeArrowOffset(userOffset: Offset | undefined | null, arrowHeight: number): Offset; +// @public +export const normalizeAutoSize: (autoSize?: PositioningOptions["autoSize"]) => NormalizedAutoSize | false; + +// @public (undocumented) +export type NormalizedAutoSize = { + applyMaxWidth: boolean; + applyMaxHeight: boolean; +}; + // @public (undocumented) export type Offset = OffsetFunction | OffsetObject | OffsetShorthand; diff --git a/packages/react-components/react-positioning/library/src/index.ts b/packages/react-components/react-positioning/library/src/index.ts index 26430e1129759..85bdc635ee639 100644 --- a/packages/react-components/react-positioning/library/src/index.ts +++ b/packages/react-components/react-positioning/library/src/index.ts @@ -12,11 +12,12 @@ export { usePositioning } from './usePositioning'; export { usePositioningMouseTarget } from './usePositioningMouseTarget'; export { useSafeZoneArea } from './hooks/useSafeZoneArea/useSafeZoneArea'; export type { UseSafeZoneOptions } from './hooks/useSafeZoneArea/useSafeZoneArea'; -export { resolvePositioningShorthand, mergeArrowOffset } from './utils/index'; +export { normalizeAutoSize, resolvePositioningShorthand, mergeArrowOffset } from './utils/index'; export type { Alignment, AutoSize, + NormalizedAutoSize, // eslint-disable-next-line @typescript-eslint/no-deprecated Boundary, Offset,