diff --git a/apps/public-docsite-v9-headless/src/PolyfillsAndFallbacks.mdx b/apps/public-docsite-v9-headless/src/PolyfillsAndFallbacks.mdx index 5b14381d246f1c..b6624bb407f7df 100644 --- a/apps/public-docsite-v9-headless/src/PolyfillsAndFallbacks.mdx +++ b/apps/public-docsite-v9-headless/src/PolyfillsAndFallbacks.mdx @@ -19,9 +19,11 @@ experience to provide. Fluent UI Headless leaves that tradeoff to the consuming anchor-positioning polyfill. This keeps the common path lean and requires little application code, although a polyfill may implement only part of the positioning behavior listed in the [browser support matrix](?path=/docs/overview-browser-support--docs#detailed-feature-support). -2. **Use product-owned positioning logic.** When `CSS.supports('anchor-name', '--x')` returns `false`, - hand the layer to a measurement-based positioner. This adds implementation work, but lets the - product control placement, flipping, and collision handling. +2. **Use Floating UI as a fallback.** Wrap the app in `PositioningProvider mode="fallback"` from + `@fluentui/react-headless-components-preview/positioning-floating-ui`. Surfaces keep native CSS anchor + positioning where the browser supports `position-area`, and hand over to Floating UI where it does not + or where they use options CSS cannot express (`autoSize`, boundaries, …). This is the same positioner + Fluent UI React v9 uses, and it is only bundled when you import that entry. 3. **Allow static placement.** Keep the native fallback behavior: the surface opens, but it may appear at its document position rather than beside the trigger. Products aimed primarily at browsers with native support may find this sufficient. @@ -57,8 +59,21 @@ if (!hasPopover) { Overlays are positioned relative to their trigger with [CSS anchor positioning](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_anchor_positioning). -The headless library does **not** include a JavaScript positioning fallback, so without support these -overlays render at their static position. Use the feature detection and fallback options above, and -load any polyfill before Fluent UI Headless renders. Consult the +The headless library does **not** bundle a JavaScript positioning fallback, so without support these +overlays render at their static position. Either load a polyfill before Fluent UI Headless renders, or +opt into Floating UI as a fallback: + +```tsx +import { PositioningProvider } from '@fluentui/react-headless-components-preview/positioning-floating-ui'; + + + +; +``` + +The fallback checks `position-area` rather than `anchor-name`: Chromium 125–128 support `anchor-name` +but not `position-area`, which the headless CSS path relies on. + +Consult the [browser support matrix](?path=/docs/overview-browser-support--docs#detailed-feature-support) for the individual CSS properties Fluent UI Headless uses. diff --git a/change/@fluentui-react-headless-components-preview-8c1f2a3e-positioning-engine.json b/change/@fluentui-react-headless-components-preview-8c1f2a3e-positioning-engine.json new file mode 100644 index 00000000000000..619a176ba8194e --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-8c1f2a3e-positioning-engine.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "feat: add PositioningProvider (positioning-floating-ui entry) to position surfaces with Floating UI as a fallback or everywhere", + "packageName": "@fluentui/react-headless-components-preview", + "email": "vgenaev@gmail.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-positioning-8c1f2a3e-positioning-engine.json b/change/@fluentui-react-positioning-8c1f2a3e-positioning-engine.json new file mode 100644 index 00000000000000..12e27ae2c419c8 --- /dev/null +++ b/change/@fluentui-react-positioning-8c1f2a3e-positioning-engine.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: add floatingUIPositioningEngine; fix: honor an explicit `strategy` instead of always resolving it to `fixed`", + "packageName": "@fluentui/react-positioning", + "email": "vgenaev@gmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-headless-components-preview/library/bundle-isolation.config.json b/packages/react-components/react-headless-components-preview/library/bundle-isolation.config.json index 3042e4e6862544..233765b603ee2f 100644 --- a/packages/react-components/react-headless-components-preview/library/bundle-isolation.config.json +++ b/packages/react-components/react-headless-components-preview/library/bundle-isolation.config.json @@ -2,7 +2,7 @@ "$schema": "../../../../tools/verify-bundle-isolation/schema.json", "fixturesRoot": "./bundle-size", "externals": ["react", "react-dom", "react/jsx-runtime", "react/compiler-runtime"], - "forbiddenPackages": ["tabster", "@griffel/*", "@fluentui/react-icons"], + "forbiddenPackages": ["tabster", "@griffel/*", "@fluentui/react-icons", "@floating-ui/*"], "fixtures": { "AllComponents.fixture.js": {} } diff --git a/packages/react-components/react-headless-components-preview/library/etc/avatar-group.api.md b/packages/react-components/react-headless-components-preview/library/etc/avatar-group.api.md index 06a694c68c15a1..bd3982fdc87969 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/avatar-group.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/avatar-group.api.md @@ -23,7 +23,7 @@ import type { JSXElement } from '@fluentui/react-utilities'; import { PartitionAvatarGroupItems } from '@fluentui/react-avatar'; import { partitionAvatarGroupItems } from '@fluentui/react-avatar'; import { PartitionAvatarGroupItemsOptions } from '@fluentui/react-avatar'; -import type { PositioningProps as PositioningProps_2 } from '@fluentui/react-positioning'; +import type { PositioningProps } from '@fluentui/react-positioning'; import type { PositioningShorthandValue } from '@fluentui/react-positioning'; import type * as React_2 from 'react'; import { renderAvatarGroup_unstable as renderAvatarGroup } from '@fluentui/react-avatar'; diff --git a/packages/react-components/react-headless-components-preview/library/etc/combobox.api.md b/packages/react-components/react-headless-components-preview/library/etc/combobox.api.md index 8981e7e30adfab..8acbd833422ff0 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/combobox.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/combobox.api.md @@ -20,7 +20,7 @@ import { OptionGroupState } from '@fluentui/react-combobox'; import { OptionProps } from '@fluentui/react-combobox'; import { OptionSlots } from '@fluentui/react-combobox'; import type { OptionState as OptionState_2 } from '@fluentui/react-combobox'; -import type { PositioningProps as PositioningProps_2 } from '@fluentui/react-positioning'; +import type { PositioningProps } from '@fluentui/react-positioning'; import type { PositioningShorthandValue } from '@fluentui/react-positioning'; import type * as React_2 from 'react'; import { renderListbox_unstable as renderListbox } from '@fluentui/react-combobox'; diff --git a/packages/react-components/react-headless-components-preview/library/etc/dropdown.api.md b/packages/react-components/react-headless-components-preview/library/etc/dropdown.api.md index d4bf188cc83e99..e9a93c41f7c33a 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/dropdown.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/dropdown.api.md @@ -19,7 +19,7 @@ import { OptionGroupState } from '@fluentui/react-combobox'; import { OptionProps } from '@fluentui/react-combobox'; import { OptionSlots } from '@fluentui/react-combobox'; import type { OptionState as OptionState_2 } from '@fluentui/react-combobox'; -import type { PositioningProps as PositioningProps_2 } from '@fluentui/react-positioning'; +import type { PositioningProps } from '@fluentui/react-positioning'; import type { PositioningShorthandValue } from '@fluentui/react-positioning'; import type * as React_2 from 'react'; import { renderListbox_unstable as renderListbox } from '@fluentui/react-combobox'; diff --git a/packages/react-components/react-headless-components-preview/library/etc/info-label.api.md b/packages/react-components/react-headless-components-preview/library/etc/info-label.api.md index 07d06d34f44dcb..9da669c2d4dc4c 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/info-label.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/info-label.api.md @@ -11,7 +11,7 @@ import type { EventHandler } from '@fluentui/react-utilities'; import type { ForwardRefComponent } from '@fluentui/react-utilities'; import type { JSXElement } from '@fluentui/react-utilities'; import { LabelBaseProps } from '@fluentui/react-label'; -import type { PositioningProps as PositioningProps_2 } from '@fluentui/react-positioning'; +import type { PositioningProps } from '@fluentui/react-positioning'; import type { PositioningShorthandValue } from '@fluentui/react-positioning'; import * as React_2 from 'react'; import type { Slot } from '@fluentui/react-utilities'; diff --git a/packages/react-components/react-headless-components-preview/library/etc/menu.api.md b/packages/react-components/react-headless-components-preview/library/etc/menu.api.md index 47225a7d4c8ab4..a637e8c9b475ab 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/menu.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/menu.api.md @@ -49,7 +49,7 @@ import { MenuBaseState as MenuState } from '@fluentui/react-menu'; import { MenuTriggerChildProps } from '@fluentui/react-menu'; import { MenuTriggerProps } from '@fluentui/react-menu'; import { MenuTriggerState } from '@fluentui/react-menu'; -import type { PositioningProps as PositioningProps_2 } from '@fluentui/react-positioning'; +import type { PositioningProps } from '@fluentui/react-positioning'; import type { PositioningShorthandValue } from '@fluentui/react-positioning'; import * as React_2 from 'react'; import { renderMenuDivider_unstable as renderMenuDivider } from '@fluentui/react-menu'; diff --git a/packages/react-components/react-headless-components-preview/library/etc/popover.api.md b/packages/react-components/react-headless-components-preview/library/etc/popover.api.md index 3a42a8eaacc18c..1f125597b33153 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/popover.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/popover.api.md @@ -13,7 +13,7 @@ import type { EventHandler } from '@fluentui/react-utilities'; import type { ForwardRefComponent } from '@fluentui/react-utilities'; import type { JSXElement } from '@fluentui/react-utilities'; import type { PopoverTriggerChildProps as PopoverTriggerChildProps_2 } from '@fluentui/react-popover'; -import type { PositioningProps as PositioningProps_2 } from '@fluentui/react-positioning'; +import type { PositioningProps } from '@fluentui/react-positioning'; import type { PositioningShorthandValue } from '@fluentui/react-positioning'; import * as React_2 from 'react'; import type { Slot } from '@fluentui/react-utilities'; @@ -35,10 +35,7 @@ export const Popover: { // @public export type PopoverContextValue = Pick & { - positioning: { - targetRef: React_2.RefCallback; - containerRef: React_2.RefCallback; - }; + positioning: PositioningReturn; }; // @public diff --git a/packages/react-components/react-headless-components-preview/library/etc/positioning-floating-ui.api.md b/packages/react-components/react-headless-components-preview/library/etc/positioning-floating-ui.api.md new file mode 100644 index 00000000000000..19fd120018665d --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/etc/positioning-floating-ui.api.md @@ -0,0 +1,27 @@ +## API Report File for "@fluentui/react-headless-components-preview" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { JSXElement } from '@fluentui/react-utilities'; +import * as React_2 from 'react'; + +// @public +export type PositioningMode = 'fallback' | 'floating-ui' | 'css'; + +// @public +export const PositioningProvider: { + (props: PositioningProviderProps): JSXElement; + displayName: string; +}; + +// @public (undocumented) +export type PositioningProviderProps = { + mode: PositioningMode; + children?: React_2.ReactNode; +}; + +// (No @packageDocumentation comment for this package) + +``` 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 8125ce41767032..76d1a11f00ca4b 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/positioning.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/positioning.api.md @@ -7,7 +7,7 @@ import { Alignment } from '@fluentui/react-positioning'; import { Position } from '@fluentui/react-positioning'; import { PositioningImperativeRef } from '@fluentui/react-positioning'; -import type { PositioningProps as PositioningProps_2 } from '@fluentui/react-positioning'; +import { PositioningProps } from '@fluentui/react-positioning'; import { PositioningShorthandValue } from '@fluentui/react-positioning'; import type * as React_2 from 'react'; @@ -27,13 +27,13 @@ export { Position } export { PositioningImperativeRef } -// @public (undocumented) -export type PositioningProps = Pick; +export { PositioningProps } // @public (undocumented) export type PositioningReturn = { targetRef: React_2.RefCallback; containerRef: React_2.RefCallback; + arrowRef: React_2.RefCallback; }; // @public (undocumented) @@ -52,7 +52,7 @@ export const POSITIONS: { // @public (undocumented) export const resolvePositioningShorthand: ResolvePositioningShorthand; -// @public (undocumented) +// @public export function usePositioning(options: PositioningProps): PositioningReturn; // (No @packageDocumentation comment for this package) diff --git a/packages/react-components/react-headless-components-preview/library/etc/tag-picker.api.md b/packages/react-components/react-headless-components-preview/library/etc/tag-picker.api.md index 55d35401ca159a..7dd3b39c2892ca 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/tag-picker.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/tag-picker.api.md @@ -11,7 +11,7 @@ import type { JSXElement } from '@fluentui/react-utilities'; import { ListboxProps } from '@fluentui/react-combobox'; import { OptionSlots } from '@fluentui/react-combobox'; import type { OptionState as OptionState_2 } from '@fluentui/react-combobox'; -import type { PositioningProps as PositioningProps_2 } from '@fluentui/react-positioning'; +import type { PositioningProps } from '@fluentui/react-positioning'; import type { PositioningShorthandValue } from '@fluentui/react-positioning'; import type * as React_2 from 'react'; import { renderTagPickerButton_unstable as renderTagPickerButton } from '@fluentui/react-tag-picker'; diff --git a/packages/react-components/react-headless-components-preview/library/etc/teaching-popover.api.md b/packages/react-components/react-headless-components-preview/library/etc/teaching-popover.api.md index f46f42a2206954..0b8addfca14607 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/teaching-popover.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/teaching-popover.api.md @@ -13,7 +13,7 @@ import type { ForwardRefComponent } from '@fluentui/react-utilities'; import type { JSXElement } from '@fluentui/react-utilities'; import type { PopoverContextValue as PopoverContextValue_2 } from '@fluentui/react-popover'; import type { PopoverTriggerChildProps } from '@fluentui/react-popover'; -import type { PositioningProps as PositioningProps_2 } from '@fluentui/react-positioning'; +import type { PositioningProps } from '@fluentui/react-positioning'; import type { PositioningShorthandValue } from '@fluentui/react-positioning'; import * as React_2 from 'react'; import { renderTeachingPopoverBody_unstable as renderTeachingPopoverBody } from '@fluentui/react-teaching-popover'; diff --git a/packages/react-components/react-headless-components-preview/library/etc/tooltip.api.md b/packages/react-components/react-headless-components-preview/library/etc/tooltip.api.md index 3df83006766fff..cf375dd742cd81 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/tooltip.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/tooltip.api.md @@ -6,7 +6,7 @@ import type { JSXElement } from '@fluentui/react-utilities'; import { OnVisibleChangeData } from '@fluentui/react-tooltip'; -import type { PositioningProps as PositioningProps_2 } from '@fluentui/react-positioning'; +import type { PositioningProps } from '@fluentui/react-positioning'; import type { PositioningShorthandValue } from '@fluentui/react-positioning'; import type * as React_2 from 'react'; import type { TooltipBaseProps } from '@fluentui/react-tooltip'; diff --git a/packages/react-components/react-headless-components-preview/library/package.json b/packages/react-components/react-headless-components-preview/library/package.json index d8df5de297cff5..85f7bbe97bc8b7 100644 --- a/packages/react-components/react-headless-components-preview/library/package.json +++ b/packages/react-components/react-headless-components-preview/library/package.json @@ -392,6 +392,16 @@ "default": "./lib-commonjs/positioning.cjs" } }, + "./positioning-floating-ui": { + "import": { + "types": "./dist/positioning-floating-ui.d.ts", + "default": "./lib/positioning-floating-ui.js" + }, + "require": { + "types": "./dist/positioning-floating-ui.d.cts", + "default": "./lib-commonjs/positioning-floating-ui.cjs" + } + }, "./progress-bar": { "import": { "types": "./dist/progress-bar.d.ts", diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.types.ts index 0a062ecf08fa1a..71de2855441c8e 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.types.ts @@ -132,8 +132,5 @@ export type PopoverContextValue = Pick< | 'surfaceId' | 'trapFocus' > & { - positioning: { - targetRef: React.RefCallback; - containerRef: React.RefCallback; - }; + positioning: PositioningReturn; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverSurface/usePopoverSurface.ts b/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverSurface/usePopoverSurface.ts index 84faf520ee4948..28cf5689e76014 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverSurface/usePopoverSurface.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverSurface/usePopoverSurface.ts @@ -34,7 +34,7 @@ export const usePopoverSurface = ( const state: PopoverSurfaceState = { withArrow, - arrowRef, + arrowRef: useMergedRefs(arrowRef, positioningCtx.arrowRef), components: { root: 'dialog' }, root: slot.always( { diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/PositioningEngine.cy.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Popover/PositioningEngine.cy.tsx new file mode 100644 index 00000000000000..e058c008fe06e9 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/PositioningEngine.cy.tsx @@ -0,0 +1,154 @@ +import * as React from 'react'; +import { mount as mountBase } from '@fluentui/scripts-cypress'; +import type { JSXElement } from '@fluentui/react-utilities'; +import { Popover } from './Popover'; +import { PopoverTrigger } from './PopoverTrigger/PopoverTrigger'; +import { PopoverSurface } from './PopoverSurface/PopoverSurface'; +import { PositioningProvider } from '../../positioning-floating-ui'; +import type { PositioningMode } from '../../positioning-floating-ui'; +import type { PositioningShorthand } from '../../positioning'; + +const mount = (element: JSXElement) => { + mountBase(element); +}; + +const surfaceSelector = '[data-popover-surface]'; + +const PositionedPopover = (props: { positioning: PositioningShorthand; withArrow?: boolean; tall?: boolean }) => ( +
+ + + + + {props.tall ?
Tall content
: 'Surface'}
+
+
+); + +const mountWithMode = (mode: PositioningMode, props: React.ComponentProps) => + mount( + + + , + ); + +/** `position-area` is missing in the React 17 run's Electron 118, where every fallback surface takes the engine. */ +const supportsPositionArea = (win: Window & typeof globalThis) => + Boolean(win.CSS?.supports?.('position-area', 'bottom')); + +const expectCssAnchorPositioning = () => + cy.get(surfaceSelector).should($el => { + expect($el).to.have.length(1); + expect($el[0]).not.to.have.attr('data-popper-placement'); + expect($el[0].style.getPropertyValue('position-anchor')).to.match(/^--popover-anchor-/); + }); + +describe('Popover positioning', () => { + it('positions with CSS anchor positioning by default', () => { + mount( + + + + + Surface + , + ); + + cy.get(surfaceSelector).should($el => { + expect($el[0]).not.to.have.attr('data-popper-placement'); + expect($el[0].style.transform).to.equal(''); + }); + + cy.window().then(win => { + if (supportsPositionArea(win)) { + cy.get(surfaceSelector).should('have.attr', 'data-placement', 'below-start'); + expectCssAnchorPositioning(); + } + }); + }); + + describe('PositioningProvider mode="floating-ui"', () => { + it('positions every surface with Floating UI', () => { + mountWithMode('floating-ui', { positioning: { position: 'below', align: 'start' } }); + + cy.get(surfaceSelector) + .should('have.attr', 'data-popper-placement', 'bottom-start') + .and('have.attr', 'data-placement', 'below-start') + .and($el => { + expect($el[0].style.getPropertyValue('position-anchor')).to.equal(''); + expect($el[0].style.transform).to.match(/translate/); + expect($el[0].style.position).to.equal('fixed'); + }); + + cy.get('button').then($trigger => { + const trigger = $trigger[0].getBoundingClientRect(); + cy.get(surfaceSelector).then($surface => { + const surface = $surface[0].getBoundingClientRect(); + expect(Math.round(surface.left)).to.equal(Math.round(trigger.left)); + expect(surface.top).to.be.closeTo(trigger.bottom, 1); + }); + }); + }); + + it('applies engine-only options such as autoSize', () => { + mountWithMode('floating-ui', { positioning: { position: 'below', autoSize: true }, tall: true }); + + cy.get(surfaceSelector).should($el => { + expect($el[0].style.maxHeight).to.match(/px$/); + expect($el[0].getBoundingClientRect().bottom).to.be.at.most(window.innerHeight + 1); + }); + }); + + it('positions the arrow', () => { + mountWithMode('floating-ui', { positioning: 'after', withArrow: true }); + + cy.get(surfaceSelector).should('have.attr', 'data-popper-placement', 'right'); + cy.get('[data-arrow]').should($arrow => { + expect($arrow[0].style.top).to.match(/px$/); + }); + }); + }); + + describe('PositioningProvider mode="fallback"', () => { + it('keeps CSS anchor positioning for a surface that does not need Floating UI', () => { + mountWithMode('fallback', { positioning: { position: 'below' } }); + + cy.window().then(win => { + if (supportsPositionArea(win)) { + expectCssAnchorPositioning(); + } else { + cy.get(surfaceSelector).should('have.attr', 'data-popper-placement', 'bottom'); + } + }); + }); + + it('hands over to Floating UI for a surface with engine-only options', () => { + mountWithMode('fallback', { positioning: { position: 'below', autoSize: true } }); + + cy.get(surfaceSelector) + .should('have.attr', 'data-popper-placement', 'bottom') + .and($el => { + expect($el[0].style.getPropertyValue('position-anchor')).to.equal(''); + }); + }); + }); + + describe('PositioningProvider mode="css"', () => { + it('opts a subtree back to CSS anchor positioning inside another provider', () => { + mount( + + + + + , + ); + + cy.get(surfaceSelector).should('not.have.attr', 'data-popper-placement'); + cy.window().then(win => { + if (supportsPositionArea(win)) { + expectCssAnchorPositioning(); + } + }); + }); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/popoverContext.ts b/packages/react-components/react-headless-components-preview/library/src/components/Popover/popoverContext.ts index 30fc3c12125179..d3002f60716885 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Popover/popoverContext.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/popoverContext.ts @@ -22,6 +22,7 @@ const popoverContextDefaultValue: PopoverContextValue = { positioning: { targetRef: () => undefined, containerRef: () => undefined, + arrowRef: () => undefined, }, surfaceId: '', }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/usePopover.ts b/packages/react-components/react-headless-components-preview/library/src/components/Popover/usePopover.ts index 0d4069cce4ff4b..9c3ef0c663490b 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Popover/usePopover.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/usePopover.ts @@ -207,10 +207,7 @@ export const usePopoverContextValues = (state: PopoverState): { popover: Popover openOnContext, withArrow, trapFocus, - positioning: { - targetRef: positioning.targetRef, - containerRef: positioning.containerRef, - }, + positioning, surfaceId, }, }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/useTooltip.ts b/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/useTooltip.ts index 301a94ad7aa499..3bd184c03d480c 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/useTooltip.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/useTooltip.ts @@ -45,6 +45,9 @@ export const useTooltip = (props: TooltipProps): TooltipState => { hideDelay = 250, } = props; + const positioningOptions = resolvePositioningShorthand(positioning); + const { targetRef, containerRef, arrowRef } = usePositioning(positioningOptions); + const state: TooltipState = { positioning, showDelay, @@ -53,6 +56,7 @@ export const useTooltip = (props: TooltipProps): TooltipState => { visible, shouldRenderTooltip: visible, withArrow, + arrowRef, // Slots components: { content: 'div', @@ -66,9 +70,6 @@ export const useTooltip = (props: TooltipProps): TooltipState => { }), }; - const positioningOptions = resolvePositioningShorthand(positioning); - const { targetRef, containerRef } = usePositioning(positioningOptions); - state.content.id = useId('tooltip-', state.content.id); state.content['data-open'] = toDataAttributeValue(state.visible); diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/PositioningEngineContext.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/PositioningEngineContext.ts new file mode 100644 index 00000000000000..5bbb0f349b7694 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/PositioningEngineContext.ts @@ -0,0 +1,16 @@ +'use client'; + +import * as React from 'react'; +import type { PositioningEngine } from '@fluentui/react-positioning'; + +const PositioningEngineContext = React.createContext(undefined); + +/** + * Internal: supplies the positioning engine to every headless component below it. Consumers select + * an engine through `PositioningProvider` from the `positioning-floating-ui` entry. + */ +export const PositioningEngineContextProvider: React.Provider = + PositioningEngineContext.Provider; + +export const usePositioningEngineContext = (): PositioningEngine | undefined => + React.useContext(PositioningEngineContext); diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/PositioningProvider.test.tsx b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/PositioningProvider.test.tsx new file mode 100644 index 00000000000000..40cd408d1cbccf --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/PositioningProvider.test.tsx @@ -0,0 +1,63 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; +import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; +import type { PositioningEngine } from '@fluentui/react-positioning'; +import { PositioningProvider } from './PositioningProvider'; +import { usePositioningEngineContext } from './PositioningEngineContext'; +import { isFallbackPositioningEngine } from './fallbackPositioningEngine'; + +function readEngine(element: (capture: React.ReactNode) => React.ReactElement) { + let engine: PositioningEngine | undefined; + const Capture = () => { + engine = usePositioningEngineContext(); + return null; + }; + render(element()); + return engine; +} + +describe('PositioningProvider', () => { + it('supplies no engine without a provider', () => { + expect(readEngine(capture => <>{capture})).toBeUndefined(); + }); + + it('supplies a fallback-wrapped Floating UI engine in fallback mode', () => { + const engine = readEngine(capture => {capture}); + + expect(engine).toBeDefined(); + expect(isFallbackPositioningEngine(engine)).toBe(true); + }); + + it('supplies the Floating UI engine as is in floating-ui mode', () => { + const engine = readEngine(capture => {capture}); + + expect(engine).toBe(floatingUIPositioningEngine); + }); + + it('supplies no engine in css mode', () => { + expect(readEngine(capture => {capture})).toBeUndefined(); + }); + + it('lets a nested provider override the mode for its subtree', () => { + const cssInsideFallback = readEngine(capture => ( + + {capture} + + )); + const floatingInsideFallback = readEngine(capture => ( + + {capture} + + )); + + expect(cssInsideFallback).toBeUndefined(); + expect(floatingInsideFallback).toBe(floatingUIPositioningEngine); + }); + + it('keeps the same engine identity across renders', () => { + const first = readEngine(capture => {capture}); + const second = readEngine(capture => {capture}); + + expect(first).toBe(second); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/PositioningProvider.tsx b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/PositioningProvider.tsx new file mode 100644 index 00000000000000..f95c74f288526f --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/PositioningProvider.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; +import type { PositioningEngine } from '@fluentui/react-positioning'; +import type { JSXElement } from '@fluentui/react-utilities'; +import { PositioningEngineContextProvider } from './PositioningEngineContext'; +import { fallbackPositioningEngine } from './fallbackPositioningEngine'; + +/** + * How headless surfaces below a `PositioningProvider` are positioned. + * + * - `fallback`: native CSS anchor positioning, handing over to Floating UI when the browser does not + * support `position-area` or a surface uses an option CSS cannot express (`autoSize`, boundaries, …). + * - `floating-ui`: Floating UI for every surface, the same positioner Fluent UI React v9 uses. + * - `css`: native CSS anchor positioning only, the default without a provider. + */ +export type PositioningMode = 'fallback' | 'floating-ui' | 'css'; + +export type PositioningProviderProps = { + mode: PositioningMode; + children?: React.ReactNode; +}; + +const ENGINES: Record = { + fallback: fallbackPositioningEngine(floatingUIPositioningEngine), + 'floating-ui': floatingUIPositioningEngine, + css: undefined, +}; + +/** + * Selects how headless surfaces below it are positioned. The nearest provider wins, so a nested + * provider overrides the mode for its subtree. + * + * @example + * ```tsx + * import { PositioningProvider } from '@fluentui/react-headless-components-preview/positioning-floating-ui'; + * + * + * + * + * ``` + */ +export const PositioningProvider = (props: PositioningProviderProps): JSXElement => ( + {props.children} +); + +PositioningProvider.displayName = 'PositioningProvider'; diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/fallbackPositioningEngine.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/fallbackPositioningEngine.ts new file mode 100644 index 00000000000000..8882e7580e0376 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/fallbackPositioningEngine.ts @@ -0,0 +1,29 @@ +import type { PositioningEngine } from '@fluentui/react-positioning'; + +const fallbackEngines = new WeakSet(); +const fallbackEnginesByEngine = new WeakMap(); + +/** + * Internal: marks a positioning engine as a fallback. Surfaces then use native CSS anchor positioning + * when the browser supports it and their options do not need an engine, and hand over to `engine` + * otherwise. Backs `PositioningProvider mode="fallback"`. + * + * Calling it again with the same engine returns the same wrapper. + */ +export function fallbackPositioningEngine(engine: PositioningEngine): PositioningEngine { + if (fallbackEngines.has(engine)) { + return engine; + } + + let fallback = fallbackEnginesByEngine.get(engine); + if (!fallback) { + fallback = { create: params => engine.create(params) }; + fallbackEngines.add(fallback); + fallbackEnginesByEngine.set(engine, fallback); + } + + return fallback; +} + +export const isFallbackPositioningEngine = (engine: PositioningEngine | undefined): boolean => + engine !== undefined && fallbackEngines.has(engine); 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 3f92a6d60b922e..2c3121d4007727 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/types.ts @@ -1,28 +1,16 @@ import type * as React from 'react'; -import type { - PositioningProps as CanonicalPositioningProps, - PositioningShorthandValue, -} from '@fluentui/react-positioning'; +import type { PositioningProps, PositioningShorthandValue } from '@fluentui/react-positioning'; export type LogicalAlignment = 'start' | 'center' | 'end'; export type PositioningReturn = { targetRef: React.RefCallback; containerRef: React.RefCallback; + arrowRef: React.RefCallback; }; -export type PositioningProps = Pick< - CanonicalPositioningProps, - | 'align' - | 'coverTarget' - | 'fallbackPositions' - | 'matchTargetSize' - | 'offset' - | 'pinned' - | 'position' - | 'positioningRef' - | 'strategy' - | 'target' ->; +// Headless accepts the canonical `PositioningProps` contract. Without `PositioningProvider` (from the +// `positioning-floating-ui` entry) only the options CSS anchor positioning can express take effect. +export type { PositioningProps } from '@fluentui/react-positioning'; export type PositioningShorthand = PositioningProps | PositioningShorthandValue; diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useCssAnchorPositioning.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useCssAnchorPositioning.ts new file mode 100644 index 00000000000000..f2f7982ea83ddd --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useCssAnchorPositioning.ts @@ -0,0 +1,251 @@ +'use client'; + +import * as React from 'react'; +import { useId, useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; +import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; +import type { PositioningShorthandValue, PositioningVirtualElement } from '@fluentui/react-positioning'; +import type { PositioningProps } 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 { usePlacementObserver } from './usePlacementObserver'; + +export type TargetElement = HTMLElement | PositioningVirtualElement; + +const DEFAULT_FLIP = ['flip-block', 'flip-inline', 'flip-block flip-inline']; + +const EMPTY_FALLBACK_POSITIONS: PositioningShorthandValue[] = []; + +/** + * Reads the current anchor-name property from an element and parses it into an array of names. + * Handles comma-separated values and trimming. + */ +const readAnchorNames = (element: HTMLElement): string[] => { + return element.style + .getPropertyValue('anchor-name') + .split(',') + .map(name => name.trim()) + .filter(Boolean); +}; + +/** + * Inline styles written by `containerRef`, removed when CSS anchor positioning stops owning the + * container so that a positioning engine takes over a clean element. + */ +const CONTAINER_STYLE_PROPERTIES = [ + 'position', + 'inset', + 'margin', + 'margin-block-start', + 'margin-block-end', + 'margin-inline-start', + 'margin-inline-end', + 'position-anchor', + 'position-area', + 'position-try-fallbacks', + 'place-self', + 'align-self', + 'justify-self', +]; + +const MATCH_TARGET_WIDTH = 'anchor-size(width)'; + +const clearAnchorStyles = (container: HTMLElement): void => { + CONTAINER_STYLE_PROPERTIES.forEach(property => container.style.removeProperty(property)); + if (container.style.getPropertyValue('width') === MATCH_TARGET_WIDTH) { + container.style.removeProperty('width'); + } + container.removeAttribute('data-placement'); +}; + +export type UseCssAnchorPositioningOptions = Pick< + PositioningProps, + | 'align' + | 'coverTarget' + | 'fallbackPositions' + | 'matchTargetSize' + | 'offset' + | 'pinned' + | 'position' + | 'strategy' + | 'target' +> & { + /** When false, the hook keeps its state but writes nothing to the DOM. */ + enabled: boolean; +}; + +export type UseCssAnchorPositioningReturn = { + targetRef: React.RefCallback; + containerRef: React.RefCallback; + setTarget: (target: TargetElement | null) => void; + updatePosition: () => void; +}; + +/** + * Positions a surface with native CSS anchor positioning. + */ +export function useCssAnchorPositioning(options: UseCssAnchorPositioningOptions): UseCssAnchorPositioningReturn { + const { + enabled, + pinned, + target: customTarget = null, + align: alignInput = ALIGNMENTS.center, + position = POSITIONS.above, + fallbackPositions = EMPTY_FALLBACK_POSITIONS, + offset, + coverTarget = false, + strategy = 'fixed', + matchTargetSize, + } = options; + + const align = normalizeAlign(alignInput); + + const { mainAxis, crossAxis } = resolveOffset(offset); + const coverAlignment = React.useMemo( + () => (coverTarget ? getCoverSelfAlignment(position, align) : null), + [coverTarget, position, align], + ); + + const [triggerEl, setTriggerEl] = React.useState(null); + const [containerEl, setContainerEl] = React.useState(null); + const [imperativeTarget, setImperativeTarget] = React.useState(null); + const effectiveTarget = enabled ? imperativeTarget ?? resolveElementRef(customTarget) ?? triggerEl : null; + + const anchorName = `--${useId('popover-anchor-')}`; + const positionArea = POSITION_AREA_MAP[position][align]; + const placement = getPlacementString(position, align); + + const { targetDocument } = useFluent(); + + const fallbackAreas = React.useMemo(() => fallbackPositions.map(shorthandToPositionArea), [fallbackPositions]); + + const updatePosition = usePlacementObserver(containerEl, effectiveTarget, targetDocument, coverTarget || !enabled); + + const setTarget = React.useCallback((el: TargetElement | null) => { + setImperativeTarget(resolveElementRef(el)); + }, []); + + useIsomorphicLayoutEffect(() => { + if (!effectiveTarget) { + return; + } + + // `anchor-name` is a comma-separated list. Append this instance's name + // instead of overwriting so that multiple positioned popovers can share a + // single trigger (e.g. a Tooltip on hover and a Menu on click attached to + // the same button) without clobbering each other's anchor. On cleanup we + // remove only our own name, preserving any others still in use. + if (anchorName) { + const names = readAnchorNames(effectiveTarget); + if (!names.includes(anchorName)) { + effectiveTarget.style.setProperty('anchor-name', [...names, anchorName].join(', ')); + } + } + + return () => { + if (anchorName) { + const remaining = readAnchorNames(effectiveTarget).filter(name => name !== anchorName); + if (remaining.length > 0) { + effectiveTarget.style.setProperty('anchor-name', remaining.join(', ')); + } else { + effectiveTarget.style.removeProperty('anchor-name'); + } + } + }; + }, [effectiveTarget, anchorName]); + + // Styles are written from `containerRef`; release them when this hook stops owning the container + // (an engine took over, or the container changed). Layout-effect cleanups run before the next + // positioner's layout effects, so the handover happens within a single commit. + useIsomorphicLayoutEffect(() => { + if (!enabled || !containerEl) { + return; + } + + return () => clearAnchorStyles(containerEl); + }, [enabled, containerEl]); + + const targetRef: React.RefCallback = React.useCallback(node => { + setTriggerEl(node); + }, []); + + const containerRef: React.RefCallback = React.useCallback( + node => { + setContainerEl(node); + + if (!node || !enabled) { + return; + } + + node.style.setProperty('position', strategy); + node.style.setProperty('inset', 'auto'); + node.style.setProperty('margin', '0'); + + applyOffset(node, position, mainAxis, crossAxis); + + if (matchTargetSize === 'width') { + node.style.setProperty('width', MATCH_TARGET_WIDTH); + } else { + node.style.removeProperty('width'); + } + + node.style.setProperty('position-anchor', anchorName); + node.setAttribute('data-placement', placement); + + if (coverAlignment) { + node.style.setProperty('position-area', 'center'); + node.style.setProperty('align-self', coverAlignment.alignSelf); + node.style.setProperty('justify-self', coverAlignment.justifySelf); + node.style.removeProperty('position-try-fallbacks'); + return; + } + + node.style.setProperty('position-area', positionArea); + + /* + * 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`. + */ + if (align === ALIGNMENTS.center) { + node.style.setProperty('place-self', 'anchor-center'); + } else { + node.style.removeProperty('place-self'); + node.style.removeProperty('align-self'); + node.style.removeProperty('justify-self'); + } + + if (pinned) { + node.style.removeProperty('position-try-fallbacks'); + return; + } + + if (fallbackAreas.length > 0) { + node.style.setProperty('position-try-fallbacks', fallbackAreas.join(', ')); + } else { + node.style.setProperty('position-try-fallbacks', DEFAULT_FLIP.join(', ')); + } + }, + [ + enabled, + anchorName, + positionArea, + placement, + fallbackAreas, + pinned, + position, + align, + mainAxis, + crossAxis, + coverAlignment, + strategy, + matchTargetSize, + ], + ); + + return React.useMemo( + () => ({ targetRef, containerRef, setTarget, updatePosition }), + [targetRef, containerRef, setTarget, updatePosition], + ); +} diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useEnginePositioning.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useEnginePositioning.ts new file mode 100644 index 00000000000000..7746a8da6357a8 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useEnginePositioning.ts @@ -0,0 +1,185 @@ +'use client'; + +import * as React from 'react'; +import { canUseDOM, useEventCallback, useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; +import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; +import type { + OnPositioningEndEvent, + PositioningEngine, + PositioningOptions, + PositioningVirtualElement, + PositionManager, +} from '@fluentui/react-positioning'; +import type { PositioningProps } from './types'; +import { ALIGNMENTS, POSITIONS } from './constants'; +import { getPlacementString } from './utils'; + +type TargetElement = HTMLElement | PositioningVirtualElement; + +export type UseEnginePositioningOptions = Omit & { + /** `undefined` disables the hook: it keeps its state but never creates a manager. */ + engine: PositioningEngine | undefined; +}; + +export type UseEnginePositioningReturn = { + targetRef: React.RefCallback; + containerRef: React.RefCallback; + arrowRef: React.RefCallback; + setTarget: (target: TargetElement | null) => void; + updatePosition: () => void; +}; + +/** + * Memoizes positioning options field-by-field, so a fresh options object with equal values does not + * recreate the engine's position manager. Mirrors `usePositioningOptions` in react-positioning. + */ +function useStablePositioningOptions(options: UseEnginePositioningOptions): PositioningOptions { + const { + align, + arrowPadding, + autoSize, + coverTarget, + disableUpdateOnResize, + fallbackPositions, + flipBoundary, + matchTargetSize, + offset, + overflowBoundary, + overflowBoundaryPadding, + pinned, + position, + shiftToCoverTarget, + strategy, + useTransform, + } = options; + + return React.useMemo( + () => ({ + align, + arrowPadding, + autoSize, + coverTarget, + disableUpdateOnResize, + fallbackPositions, + flipBoundary, + matchTargetSize, + offset, + overflowBoundary, + overflowBoundaryPadding, + pinned, + position, + shiftToCoverTarget, + strategy, + useTransform, + }), + [ + align, + arrowPadding, + autoSize, + coverTarget, + disableUpdateOnResize, + fallbackPositions, + flipBoundary, + matchTargetSize, + offset, + overflowBoundary, + overflowBoundaryPadding, + pinned, + position, + shiftToCoverTarget, + strategy, + useTransform, + ], + ); +} + +/** + * Positions a surface with an injected {@link PositioningEngine}. + * + * The engine is plain data: it is only ever invoked inside a layout effect, so it can come from props + * or context and change identity freely without affecting hook order. Everything about the positioned + * box — coordinates, top-layer reset, `data-placement` — is the engine's responsibility (see the + * `PositioningEngine` contract); this hook only wires elements and options to it. + */ +export function useEnginePositioning(options: UseEnginePositioningOptions): UseEnginePositioningReturn { + const { engine, target: customTarget = null } = options; + const enabled = engine !== undefined; + + const { dir, targetDocument } = useFluent(); + + const [triggerEl, setTriggerEl] = React.useState(null); + const [containerEl, setContainerEl] = React.useState(null); + const [arrowEl, setArrowEl] = React.useState(null); + const [imperativeTarget, setImperativeTarget] = React.useState(null); + const managerRef = React.useRef(null); + + const effectiveTarget = enabled ? imperativeTarget ?? customTarget ?? triggerEl : null; + const initialPlacement = getPlacementString(options.position ?? POSITIONS.above, options.align ?? ALIGNMENTS.center); + + const stableOptions = useStablePositioningOptions(options); + const onPositioningEnd = useEventCallback((event: OnPositioningEndEvent) => options.onPositioningEnd?.(event)); + + useIsomorphicLayoutEffect(() => { + if (!engine || !containerEl || !canUseDOM()) { + return; + } + + // Seed the requested placement so placement-keyed styling is right before the engine's first + // (asynchronous) update reports the resolved one. The engine snapshots it and restores it on dispose. + containerEl.setAttribute('data-placement', initialPlacement); + + if (!effectiveTarget) { + return; + } + + const manager = engine.create({ + container: containerEl, + target: effectiveTarget, + arrow: arrowEl, + options: { ...stableOptions, onPositioningEnd }, + dir, + targetDocument, + }); + managerRef.current = manager; + + return () => { + manager.dispose(); + managerRef.current = null; + }; + }, [ + engine, + containerEl, + effectiveTarget, + arrowEl, + stableOptions, + onPositioningEnd, + dir, + targetDocument, + initialPlacement, + ]); + + const targetRef: React.RefCallback = React.useCallback(node => { + setTriggerEl(node); + }, []); + + const arrowRef: React.RefCallback = React.useCallback(node => { + setArrowEl(node); + }, []); + + const containerRef: React.RefCallback = React.useCallback(node => { + setContainerEl(node); + }, []); + + const setTarget = React.useCallback((el: TargetElement | null) => { + setImperativeTarget(el); + }, []); + + const updatePosition = React.useCallback(() => { + managerRef.current?.updatePosition(); + }, []); + + return React.useMemo( + () => ({ targetRef, containerRef, arrowRef, setTarget, updatePosition }), + [targetRef, containerRef, arrowRef, setTarget, updatePosition], + ); +} 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 cadfd8afc62d67..f77176808d9ccd 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 @@ -1,6 +1,9 @@ import * as React from 'react'; import { act, render } from '@testing-library/react'; +import type { PositioningEngine, PositioningImperativeRef, PositionManager } from '@fluentui/react-positioning'; import { usePositioning } from './usePositioning'; +import { PositioningEngineContextProvider, usePositioningEngineContext } from './PositioningEngineContext'; +import { fallbackPositioningEngine } from './fallbackPositioningEngine'; import { getPlacementString } from './utils/placement'; import type { PositioningProps, PositioningReturn } from './types'; @@ -87,7 +90,7 @@ describe('usePositioning', () => { expect(node).toHaveStyle({ positionArea: 'block-end span-inline-end' }); }); - it('containerRef writes position: absolute by default and clears the UA inset/margin defaults', () => { + it('containerRef writes position: fixed by default and clears the UA inset/margin defaults', () => { const result = mountHook(); const node = document.createElement('div'); @@ -230,6 +233,331 @@ describe('usePositioning', () => { expect(() => positioningRef.current?.updatePosition()).not.toThrow(); }); }); + + describe('engine', () => { + type FakeEngine = PositioningEngine & { + create: jest.Mock>; + manager: { updatePosition: jest.Mock; dispose: jest.Mock }; + }; + + const createFakeEngine = (): FakeEngine => { + const manager = { updatePosition: jest.fn(), dispose: jest.fn() }; + const engine = { manager, create: jest.fn(() => manager) }; + return engine as unknown as FakeEngine; + }; + + const Positioned = (props: { + positioning: PositioningProps; + withArrow?: boolean; + onResult?: (result: PositioningReturn) => void; + }) => { + const result = usePositioning(props.positioning); + props.onResult?.(result); + + return ( + <> + + + + position-area Positioned by the browser. + + + + + + + + + floating-ui Positioned by Floating UI. + + + + + + +
+

mode="floating-ui": Floating UI for every surface

+

+ Identical positioning props. The left surface is laid out by the browser with{' '} + position-area; the right one is wrapped in{' '} + PositioningProvider mode="floating-ui", so Floating UI computes left/ + top instead and no anchor CSS is written. The nearest provider wins, so wrapping a single surface + overrides the mode for it alone. +

+
+ + + + + + position-area Positioned by the browser. + + + + + + + + + + floating-ui Positioned by Floating UI. + + + +
+
+ +
+

Arrows

+

+ Floating UI also receives the withArrow element and writes its left/top{' '} + along the anchored edge; CSS only paints it. +

+ +
+ + + + + + Positioned by Floating UI. + + + + + + + + + Flips to before near the right edge. + + +
+
+
+ +); + +Engine.parameters = { + docs: { + description: { + story: descriptionMd, + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineAutoSize.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineAutoSize.stories.tsx new file mode 100644 index 00000000000000..c6a315d16bbe17 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineAutoSize.stories.tsx @@ -0,0 +1,72 @@ +import * as React from 'react'; +import { Menu, MenuTrigger, MenuPopover, MenuList, MenuItem } from '@fluentui/react-headless-components-preview/menu'; +import { PositioningProvider } from '@fluentui/react-headless-components-preview/positioning-floating-ui'; + +import styles from './positioning.module.css'; +import menuStyles from '../../Menu/menu.module.css'; + +export const EngineAutoSize = (): React.ReactNode => { + const [boundary, setBoundary] = React.useState(null); + const [open, setOpen] = React.useState(false); + const [itemCount, setItemCount] = React.useState(10); + + return ( + +
+
+ + +
+
+ setOpen(data.open)} + positioning={{ + overflowBoundary: boundary, + flipBoundary: boundary, + autoSize: true, + }} + > + + + + + + {Array.from({ length: itemCount }, (_, i) => ( + + Item {i} + + ))} + + + +
+
+
+ ); +}; + +EngineAutoSize.parameters = { + docs: { + description: { + story: [ + '`autoSize` sets inline `max-width` and `max-height` styles on the element to ensure it fits within the', + 'available space, and makes it scroll. Increase the item count past what fits in the dashed box to see it', + 'kick in. Requires `PositioningProvider` in `floating-ui` or `fallback` mode; component-derived options (the menu still opens `below-start`) are', + 'merged before the engine is invoked.', + ].join('\n'), + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineDescription.md b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineDescription.md new file mode 100644 index 00000000000000..597839dc522db7 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineDescription.md @@ -0,0 +1,45 @@ +By default headless surfaces are positioned by the browser with native CSS anchor positioning: no JavaScript positioner runs. `PositioningProvider` switches the surfaces below it to Floating UI, the positioner that powers Fluent UI React v9, either where CSS is not enough or everywhere. + +```tsx +import { PositioningProvider } from '@fluentui/react-headless-components-preview/positioning-floating-ui'; + +// recommended: CSS where it is enough, Floating UI where it is not + + +; + +// Floating UI for every surface below + + +; + +// back to CSS only for a subtree + +
+; +``` + +The nearest provider wins, so a nested provider overrides the mode for its subtree, down to a single surface. `@floating-ui/*` is only bundled by apps that import the `positioning-floating-ui` entry; the rest of the headless package never includes it. + +### When you need it + +Every option of the canonical `positioning` contract is accepted, but these have no CSS equivalent and only take effect under `PositioningProvider` (`fallback` or `floating-ui` mode): `autoSize`, `flipBoundary`, `overflowBoundary`, `overflowBoundaryPadding`, `shiftToCoverTarget`, `arrowPadding`, `useTransform`, `disableUpdateOnResize`, `onPositioningEnd`. Without it they log a development warning and do nothing. Browsers without CSS anchor positioning also need it, otherwise surfaces open unpositioned. + +### `fallback` mode + +Each surface keeps CSS anchor positioning and only hands over to Floating UI when + +- the browser does not support `position-area` (Chromium before 129, Safari before 26, Firefox before 147), or +- it sets an option CSS cannot express: `autoSize`, `flipBoundary`, `overflowBoundary`, `overflowBoundaryPadding`, `shiftToCoverTarget`, `arrowPadding`, `onPositioningEnd`, or a function `offset`. + +`useTransform` and `disableUpdateOnResize` only tune Floating UI, so they do not trigger the handover on their own. If the options change at runtime the surface switches between the two paths. One part of an app that needs `autoSize` therefore does not opt the rest of the app out of native anchoring. + +### `floating-ui` mode + +Every surface below is positioned by Floating UI, including those CSS could handle, so the two positioners never mix on a surface. Use it to match v9 behaviour exactly or to work around a browser issue with native anchoring; the `Engine…` stories below use it to reproduce the corresponding v9 positioning examples. + +Floating UI keeps the container positioned (including releasing the UA top-layer `inset: 0; margin: auto` that `[popover]` and `dialog:modal` receive) and keeps the container's `data-placement` current with the resolved **logical** placement (`above-start`, `after-top`, …), so placement-keyed CSS and arrows work the same way as with CSS anchor positioning. When a surface switches back to CSS it restores the inline styles and attributes it wrote. + +### Arrows + +With `withArrow`, Floating UI positions the arrow along the surface edge (`arrowPadding` keeps it away from rounded corners), but headless components have no built-in arrow size, so the gap between the surface and the target is not adjusted for it. Set `offset` to at least the arrow's protruding size, e.g. `positioning={{ offset: 8 }}` for an 8px arrow. diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineDisableTransform.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineDisableTransform.stories.tsx new file mode 100644 index 00000000000000..7562897cfbe464 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineDisableTransform.stories.tsx @@ -0,0 +1,43 @@ +import * as React from 'react'; +import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover'; +import { PositioningProvider } from '@fluentui/react-headless-components-preview/positioning-floating-ui'; + +import styles from './positioning.module.css'; + +export const EngineDisableTransform = (): React.ReactNode => ( + +
+
+ + + + + + Positioned with transform: translate3d() + + + + + + + + + Positioned with left/top + + +
+
+
+); + +EngineDisableTransform.parameters = { + docs: { + description: { + story: [ + 'By default the engine positions the element with a [CSS transform](https://developer.mozilla.org/en-US/docs/Web/CSS/transform)', + 'for better performance. Set `useTransform: false` to receive plain `left`/`top` instead, e.g. when the surface', + 'has its own transform animation. Inspect the surfaces to compare their inline styles.', + ].join('\n'), + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineFlipBoundary.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineFlipBoundary.stories.tsx new file mode 100644 index 00000000000000..256778d2410368 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineFlipBoundary.stories.tsx @@ -0,0 +1,48 @@ +import * as React from 'react'; +import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover'; +import { PositioningProvider } from '@fluentui/react-headless-components-preview/positioning-floating-ui'; + +import styles from './positioning.module.css'; + +export const EngineFlipBoundary = (): React.ReactNode => { + const [boundary, setBoundary] = React.useState(null); + + return ( + +
+
+ + + + + + Stays within the flip boundary + + + + + + + + + Overflows the flip boundary + + +
+
+
+ ); +}; + +EngineFlipBoundary.parameters = { + docs: { + description: { + story: [ + 'The flip boundary can be configured manually so that the positioned element stays within bounds for', + 'different positions. `flipBoundary` has no CSS anchor positioning equivalent (`position-try-fallbacks` always', + 'flips against the containing block), so it requires `PositioningProvider` in `floating-ui` or `fallback` mode — here supplied app-wide through', + '``.', + ].join('\n'), + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineListenToUpdates.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineListenToUpdates.stories.tsx new file mode 100644 index 00000000000000..9b8767d1cdbca3 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineListenToUpdates.stories.tsx @@ -0,0 +1,78 @@ +import * as React from 'react'; +import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover'; +import type { + PositioningImperativeRef, + PositioningProps, +} from '@fluentui/react-headless-components-preview/positioning'; +import { PositioningProvider } from '@fluentui/react-headless-components-preview/positioning-floating-ui'; + +import styles from './positioning.module.css'; + +type OnPositioningEndEvent = Parameters>[0]; + +export const EngineListenToUpdates = (): React.ReactNode => { + const [statusLog, setStatusLog] = React.useState([]); + const positioningRef = React.useRef(null); + + const onPositioningEnd = React.useCallback((event: OnPositioningEndEvent) => { + const { placement, escaped, referenceHidden } = event.detail; + const logical = (event.currentTarget as HTMLElement | null)?.getAttribute('data-placement'); + setStatusLog(log => + [ + `${new Date().toLocaleTimeString()} placement=${placement} (${logical}) escaped=${escaped} referenceHidden=${referenceHidden}`, + ...log, + ].slice(0, 8), + ); + }, []); + + return ( + +
+
+ !data.open && setStatusLog([])} + positioning={{ positioningRef, onPositioningEnd, position: 'below' }} + > + + + + + + + +
    + {statusLog.length === 0 ? ( +
  1. Open the popover to see position updates.
  2. + ) : ( + statusLog.map((entry, i) =>
  3. {entry}
  4. ) + )} +
+
+
+
+ ); +}; + +EngineListenToUpdates.parameters = { + docs: { + description: { + story: [ + 'Positioning happens outside of the React render lifecycle for performance purposes, so a position update', + 'does not need to be triggered by, or depend on, a re-render. To know when an element has been positioned,', + 'use the `onPositioningEnd` callback. `event.detail` carries the Floating UI `placement`, `escaped` and', + '`referenceHidden`; the engine has already mirrored the logical placement into `data-placement` on the surface', + 'by the time the callback runs.', + '', + 'CSS anchor positioning has no positioning lifecycle, so this callback requires `PositioningProvider` in `floating-ui` or `fallback` mode.', + '', + '> ⚠️ _Very few use cases actually require listening to position updates. This is different from the_', + '_**open/close state**, which is handled in React._', + ].join('\n'), + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundary.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundary.stories.tsx new file mode 100644 index 00000000000000..1d8e8cbf63e832 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundary.stories.tsx @@ -0,0 +1,49 @@ +import * as React from 'react'; +import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover'; +import { PositioningProvider } from '@fluentui/react-headless-components-preview/positioning-floating-ui'; + +import styles from './positioning.module.css'; + +export const EngineOverflowBoundary = (): React.ReactNode => { + const [boundary, setBoundary] = React.useState(null); + + return ( + +
+
+ + + + + + Shifted to stay within the overflow boundary + + + + + + + + + Overflows the dashed box + + +
+
+
+ ); +}; + +EngineOverflowBoundary.parameters = { + docs: { + description: { + story: [ + 'The overflow boundary can be configured manually so that the positioned element is shifted to stay within', + 'bounds for different alignments. `overflowBoundary` requires `PositioningProvider` in `floating-ui` or `fallback` mode.', + ].join('\n'), + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundaryPadding.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundaryPadding.stories.tsx new file mode 100644 index 00000000000000..2129bff1c4aa54 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundaryPadding.stories.tsx @@ -0,0 +1,80 @@ +import * as React from 'react'; +import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover'; +import { PositioningProvider } from '@fluentui/react-headless-components-preview/positioning-floating-ui'; + +import styles from './positioning.module.css'; + +export const EngineOverflowBoundaryPadding = (): React.ReactNode => { + const [boundary, setBoundary] = React.useState(null); + const [padding, setPadding] = React.useState(8); + + return ( + +
+
+ +
+
+ + + + + + {padding}px padding from every boundary side + + + + + + + + + {padding}px padding from the boundary end only + + +
+
+
+ ); +}; + +EngineOverflowBoundaryPadding.parameters = { + docs: { + description: { + story: [ + 'The `overflowBoundaryPadding` property sets the padding between the positioned element and the chosen', + 'boundary. The padding can be a shorthand number which applies to all sides, or an object that explicitly', + 'sets the padding for each side. Requires `PositioningProvider` in `floating-ui` or `fallback` mode.', + '', + '> _Design guidance recommends using **8px** or **4px** if a padding is required. Custom values are also_', + '_possible but should stay within a 4px grid._', + ].join('\n'), + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundaryRect.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundaryRect.stories.tsx new file mode 100644 index 00000000000000..843e11f96461c3 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundaryRect.stories.tsx @@ -0,0 +1,70 @@ +import * as React from 'react'; +import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover'; +import type { PositioningProps } from '@fluentui/react-headless-components-preview/positioning'; +import { PositioningProvider } from '@fluentui/react-headless-components-preview/positioning-floating-ui'; + +import styles from './positioning.module.css'; + +export const EngineOverflowBoundaryRect = (): React.ReactNode => { + const boundaryRef = React.useRef(null); + const [boundaryRect, setBoundaryRect] = React.useState(null); + + // Measured once after mount; the popovers are closed at that point so no flicker is possible. + React.useEffect(() => { + setBoundaryRect(boundaryRef.current?.getBoundingClientRect() ?? null); + }, []); + + return ( + +
+
+ Container +
+ Boundary +
+ + + + + + + Stays within the defined rect + + + + + + + + + Stays within the defined rect + + +
+
+
+ ); +}; + +EngineOverflowBoundaryRect.parameters = { + docs: { + description: { + story: [ + 'Boundaries can also be defined as `Rect` objects. This is useful when a boundary is not an actual element,', + 'but some kind of computed values. Requires `PositioningProvider` in `floating-ui` or `fallback` mode.', + ].join('\n'), + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineShiftToCoverTarget.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineShiftToCoverTarget.stories.tsx new file mode 100644 index 00000000000000..8d4a484f4afca2 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineShiftToCoverTarget.stories.tsx @@ -0,0 +1,96 @@ +import * as React from 'react'; +import { Menu, MenuTrigger, MenuPopover, MenuList, MenuItem } from '@fluentui/react-headless-components-preview/menu'; +import type { Position, PositioningImperativeRef } from '@fluentui/react-headless-components-preview/positioning'; +import { PositioningProvider } from '@fluentui/react-headless-components-preview/positioning-floating-ui'; + +import styles from './positioning.module.css'; +import menuStyles from '../../Menu/menu.module.css'; + +export const EngineShiftToCoverTarget = (): React.ReactNode => { + const [boundary, setBoundary] = React.useState(null); + const [open, setOpen] = React.useState(false); + const [itemCount, setItemCount] = React.useState(6); + const [position, setPosition] = React.useState('above'); + const positioningRef = React.useRef(null); + + // The boundary is user-resizable (`resize: both`); re-position when its size changes. + React.useEffect(() => { + if (!boundary) { + return; + } + const observer = new ResizeObserver(() => positioningRef.current?.updatePosition()); + observer.observe(boundary); + return () => observer.disconnect(); + }, [boundary]); + + return ( + +
+
+ + + +
+
+
+ setOpen(data.open)} + positioning={{ + positioningRef, + overflowBoundary: boundary, + flipBoundary: boundary, + autoSize: true, + shiftToCoverTarget: true, + position, + }} + > + + + + + + {Array.from({ length: itemCount }, (_, i) => ( + + Item {i} + + ))} + + + +
+
+
+
+ ); +}; + +EngineShiftToCoverTarget.parameters = { + docs: { + description: { + story: [ + "`shiftToCoverTarget` allows the positioned element to shift and cover the target element when there isn't", + 'enough space available to fit it. Drag the resize handle of the dashed box to shrink the available space.', + 'Requires `PositioningProvider` in `floating-ui` or `fallback` mode.', + ].join('\n'), + }, + }, +}; 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 498fe9f2b21cdb..535b526dbd29c4 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 @@ -12,6 +12,15 @@ export { FallbackPositions } from './PositioningFallbackPositions.stories'; export { FlippingBlock } from './PositioningFlippingBlock.stories'; export { FlippingInline } from './PositioningFlippingInline.stories'; export { FlippingCorner } from './PositioningFlippingCorner.stories'; +export { Engine } from './PositioningEngine.stories'; +export { EngineFlipBoundary } from './PositioningEngineFlipBoundary.stories'; +export { EngineOverflowBoundary } from './PositioningEngineOverflowBoundary.stories'; +export { EngineOverflowBoundaryRect } from './PositioningEngineOverflowBoundaryRect.stories'; +export { EngineOverflowBoundaryPadding } from './PositioningEngineOverflowBoundaryPadding.stories'; +export { EngineAutoSize } from './PositioningEngineAutoSize.stories'; +export { EngineShiftToCoverTarget } from './PositioningEngineShiftToCoverTarget.stories'; +export { EngineDisableTransform } from './PositioningEngineDisableTransform.stories'; +export { EngineListenToUpdates } from './PositioningEngineListenToUpdates.stories'; export default { title: 'Concepts/Positioning', 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 f58e3bd8b437ba..dc0bd1e0da73ff 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 @@ -327,6 +327,166 @@ font-size: 13px; } +/* ===== Engine stories (mirror the v9 positioning docs) ===== */ + +/* 300×300 boundary box, as in the v9 docsite. Modifiers arrange the triggers inside it. */ +.boundary { + box-sizing: border-box; + width: 300px; + height: 300px; + padding: var(--space-5); + border: var(--stroke-thick) dashed var(--danger); + border-radius: var(--radius-md); + display: flex; + flex-direction: column; +} + +.boundarySpaceBetween { + justify-content: space-between; +} + +.boundaryAlignEnd { + align-items: flex-end; +} + +.boundaryCenter { + align-items: center; +} + +.boundaryScroll { + padding: 0; + overflow: auto; + resize: both; +} + +.boundaryScrollInner { + height: 600px; + padding-top: 200px; + display: flex; + justify-content: center; + align-items: flex-start; +} + +.triggerBlock { + width: 150px; +} + +.triggerPushed { + margin-top: 60px; +} + +/* Overlay outline used by the `Rect` boundary demo: not an element boundary, just something to look at */ +.rectArea { + position: relative; + box-sizing: border-box; + width: 300px; + height: 300px; + padding: 60px var(--space-5) var(--space-5); + border: var(--stroke-thick) solid var(--danger); + border-radius: var(--radius-md); + display: flex; + flex-direction: column; + align-items: flex-end; + justify-content: space-between; +} + +.rectAreaLabel, +.rectBoundaryLabel { + position: absolute; + top: 0; + left: 0; + padding: 2px var(--space-2); + font-size: 11px; + font-weight: 600; +} + +.rectAreaLabel { + color: var(--text-on-accent); + background: var(--danger); +} + +.rectBoundary { + position: absolute; + top: 50px; + left: 10px; + width: 320px; + height: 320px; + outline: var(--stroke-thick) solid var(--accent); + pointer-events: none; +} + +.rectBoundaryLabel { + color: var(--text-on-accent); + background: var(--accent); +} + +.badge { + display: inline-block; + margin-inline-end: var(--space-2); + padding: 0 var(--space-2); + border-radius: var(--radius-sm); + background: var(--accent); + color: var(--accent-contrast); + font-family: monospace; + font-size: 11px; + line-height: 18px; +} + +.log { + min-width: 320px; + margin: 0; + padding: var(--space-2) var(--space-3); + border: var(--stroke-thin) dashed var(--border-strong); + border-radius: var(--radius-md); + background: var(--bg-soft); + list-style: none; + font-family: monospace; + font-size: 11px; + color: var(--text); + white-space: pre; +} + +.logEmpty { + color: var(--text-muted); + white-space: normal; +} + +/* + Arrow for engine-positioned surfaces. The engine writes the arrow's inline `left`/`top` along the + anchored edge, so CSS only paints the square and pushes it half-way out of the side named by + `data-placement`. Unlike the CSS-anchored arrow, no `margin: auto` centering must be applied. +*/ +.surfaceEngineArrow { + overflow: visible; + border: 0; + box-shadow: none; + filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.12)) drop-shadow(0 4px 8px rgba(0, 0, 0, 0.14)); +} + +.surfaceEngineArrow [data-arrow] { + position: absolute; + width: 12px; + height: 12px; + background: var(--bg-elev); + transform: rotate(45deg); +} + +.surfaceEngineArrow[data-placement^='above'] [data-arrow] { + bottom: -6px; +} + +.surfaceEngineArrow[data-placement^='below'] [data-arrow] { + top: -6px; +} + +.surfaceEngineArrow[data-placement^='before'] [data-arrow] { + right: -6px; +} + +.surfaceEngineArrow[data-placement^='after'] [data-arrow] { + left: -6px; +} + /* Adds the "Actual: " readout under any surface */ .flipReadout::after { content: 'Actual: ' attr(data-placement); 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 9e438478da05e6..1d84b50256ca64 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 @@ -1,25 +1,10 @@ import * as React from 'react'; import type { PositioningProps } from '@fluentui/react-headless-components-preview/positioning'; -/** - * 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. - */ -type SupportedPositioningProps = Omit< - PositioningProps, - | 'arrowPadding' - | 'autoSize' - | 'disableUpdateOnResize' - | 'flipBoundary' - | 'onPositioningEnd' - | 'overflowBoundary' - | 'overflowBoundaryPadding' - | 'shiftToCoverTarget' - | 'useTransform' ->; - /** * Helper component used by Storybook to auto-generate the positioning props args table. + * + * Every option of the canonical contract is accepted; options that CSS anchor positioning cannot + * express take effect only under `PositioningProvider` (see the Engine example). */ -export const Positioning: React.FC = () =>
; +export const Positioning: React.FC = () =>
; 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 9a2e65951c1e13..b9a9bae66f4b4f 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 @@ -32,12 +32,23 @@ export type CreateArrowStylesOptions = { borderColor?: GriffelStyle['borderBottomColor']; }; +// @public +export function createFloatingUIPositioningEngine(engineOptions?: CreateFloatingUIPositioningEngineOptions): PositioningEngine; + +// @public (undocumented) +export type CreateFloatingUIPositioningEngineOptions = { + configuration?: PositioningConfigurationFn; +}; + // @public @deprecated export function createSlideStyles(mainAxis: number): GriffelStyle; // @public export function createVirtualElementFromClick(nativeEvent: MouseEvent): PositioningVirtualElement; +// @public +export const floatingUIPositioningEngine: PositioningEngine; + // @internal export function mergeArrowOffset(userOffset: Offset | undefined | null, arrowHeight: number): Offset; @@ -64,6 +75,16 @@ export type OffsetObject = { // @public (undocumented) export type OffsetShorthand = number; +// @public +export type OnPositioningEndEvent = CustomEvent; + +// @public +export type OnPositioningEndEventDetail = { + placement: PositioningPlacement; + escaped: boolean; + referenceHidden: boolean; +}; + // @public (undocumented) export type Position = 'above' | 'below' | 'before' | 'after'; @@ -89,12 +110,61 @@ export type PositioningConfigurationFnOptions = Omit; +// @public +export interface PositioningEngine { + // (undocumented) + create: (params: PositioningEngineCreateParams) => PositionManager; +} + +// @public +export type PositioningEngineCreateParams = { + container: HTMLElement; + target: TargetElement; + arrow: HTMLElement | null; + options: PositioningOptions; + dir?: 'ltr' | 'rtl'; + targetDocument?: Document; +}; + // @public (undocumented) export type PositioningImperativeRef = { updatePosition: () => void; setTarget: (target: TargetElement | null) => void; }; +// @public +export interface PositioningOptions { + align?: Alignment; + arrowPadding?: number; + autoSize?: AutoSize; + coverTarget?: boolean; + disableUpdateOnResize?: boolean; + enabled?: boolean; + fallbackPositions?: PositioningShorthandValue[]; + flipBoundary?: PositioningBoundary | null; + matchTargetSize?: 'width'; + offset?: Offset; + onPositioningEnd?: (e: OnPositioningEndEvent) => void; + overflowBoundary?: PositioningBoundary | null; + overflowBoundaryPadding?: number | Partial<{ + top: number; + end: number; + bottom: number; + start: number; + }>; + pinned?: boolean; + position?: Position; + // @deprecated + positionFixed?: boolean; + shiftToCoverTarget?: boolean; + strategy?: 'absolute' | 'fixed'; + unstable_disableTether?: boolean | 'all'; + useTransform?: boolean; +} + +// @public +export type PositioningPlacement = 'top' | 'top-start' | 'top-end' | 'right' | 'right-start' | 'right-end' | 'bottom' | 'bottom-start' | 'bottom-end' | 'left' | 'left-start' | 'left-end'; + // @public export interface PositioningProps extends Pick { positioningRef?: React_2.Ref; @@ -130,6 +200,14 @@ export type PositioningVirtualElement = { contextElement?: Element; }; +// @public +export interface PositionManager { + // (undocumented) + dispose: () => void; + // (undocumented) + updatePosition: () => void; +} + // @public (undocumented) export function resolvePositioningShorthand(shorthand: PositioningShorthand | undefined | null): Readonly; diff --git a/packages/react-components/react-positioning/library/src/constants.ts b/packages/react-components/react-positioning/library/src/constants.ts index 9f2ef12574bec8..a2d0b83aa8e19d 100644 --- a/packages/react-components/react-positioning/library/src/constants.ts +++ b/packages/react-components/react-positioning/library/src/constants.ts @@ -3,6 +3,10 @@ export const DATA_POSITIONING_ESCAPED = 'data-popper-escaped'; export const DATA_POSITIONING_HIDDEN = 'data-popper-reference-hidden'; export const DATA_POSITIONING_PLACEMENT = 'data-popper-placement'; export const POSITIONING_END_EVENT = 'fui-positioningend'; +/** + * Logical placement (`PositioningShorthandValue`) written by positioning engines on the container. + */ +export const DATA_PLACEMENT = 'data-placement'; /** * CSS custom properties used to encode the slide direction for positioning-aware enter animations. diff --git a/packages/react-components/react-positioning/library/src/createFloatingUIPositioningEngine.test.ts b/packages/react-components/react-positioning/library/src/createFloatingUIPositioningEngine.test.ts new file mode 100644 index 00000000000000..e6b01e60537075 --- /dev/null +++ b/packages/react-components/react-positioning/library/src/createFloatingUIPositioningEngine.test.ts @@ -0,0 +1,238 @@ +import { computePosition } from '@floating-ui/dom'; +import { createFloatingUIPositioningEngine, floatingUIPositioningEngine } from './createFloatingUIPositioningEngine'; +import { POSITIONING_END_EVENT } from './constants'; +import type { OnPositioningEndEvent, PositioningConfigurationFn } from './types'; + +jest.mock('@floating-ui/dom', () => ({ + ...jest.requireActual('@floating-ui/dom'), + computePosition: jest.fn(), +})); + +const computePositionMock = computePosition as jest.MockedFunction; + +const flushMicrotasks = async () => { + for (let i = 0; i < 5; i++) { + await new Promise(process.nextTick); + } +}; + +function createTestElements() { + const container = document.createElement('div'); + const target = document.createElement('button'); + + document.body.appendChild(container); + document.body.appendChild(target); + + return { container, target }; +} + +describe('createFloatingUIPositioningEngine', () => { + beforeEach(() => { + computePositionMock.mockReset(); + computePositionMock.mockResolvedValue({ + x: 10, + y: 20, + placement: 'bottom-start', + strategy: 'fixed', + middlewareData: { + intersectionObserver: { intersecting: false }, + hide: { escaped: false, referenceHidden: false }, + }, + }); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('positions the container with the resolved placement and strategy', async () => { + const { container, target } = createTestElements(); + + const manager = floatingUIPositioningEngine.create({ + container, + target, + arrow: null, + options: { position: 'below', align: 'start', strategy: 'fixed' }, + }); + await flushMicrotasks(); + + expect(computePositionMock).toHaveBeenCalledWith( + target, + container, + expect.objectContaining({ placement: 'bottom-start', strategy: 'fixed' }), + ); + expect(container.style.position).toBe('fixed'); + expect(container).toHaveAttribute('data-popper-placement', 'bottom-start'); + expect(container).toHaveAttribute('data-placement', 'below-start'); + + manager.dispose(); + }); + + it('releases the UA top-layer inset before positioning', () => { + const { container, target } = createTestElements(); + container.style.inset = '0px'; + + const manager = floatingUIPositioningEngine.create({ container, target, arrow: null, options: {} }); + + expect(container.style.getPropertyValue('inset')).toBe('auto'); + + manager.dispose(); + }); + + it('writes a logical data-placement in rtl', async () => { + const { container, target } = createTestElements(); + computePositionMock.mockResolvedValue({ + x: 0, + y: 0, + placement: 'left-start', + strategy: 'fixed', + middlewareData: { + intersectionObserver: { intersecting: false }, + hide: { escaped: false, referenceHidden: false }, + }, + }); + + const manager = floatingUIPositioningEngine.create({ container, target, arrow: null, dir: 'rtl', options: {} }); + await flushMicrotasks(); + + expect(container).toHaveAttribute('data-placement', 'after-top'); + + manager.dispose(); + }); + + it('honors rtl direction when resolving the placement', async () => { + const { container, target } = createTestElements(); + + const manager = floatingUIPositioningEngine.create({ + container, + target, + arrow: null, + dir: 'rtl', + options: { position: 'after', align: 'top' }, + }); + await flushMicrotasks(); + + expect(computePositionMock).toHaveBeenCalledWith( + target, + container, + expect.objectContaining({ placement: 'left-start' }), + ); + + manager.dispose(); + }); + + it('subscribes onPositioningEnd and unsubscribes on dispose', async () => { + const { container, target } = createTestElements(); + const onPositioningEnd = jest.fn(); + + const manager = floatingUIPositioningEngine.create({ + container, + target, + arrow: null, + options: { onPositioningEnd }, + }); + await flushMicrotasks(); + + expect(onPositioningEnd).toHaveBeenCalledTimes(1); + expect(onPositioningEnd.mock.calls[0][0].detail.placement).toBe('bottom-start'); + + manager.dispose(); + container.dispatchEvent(new CustomEvent(POSITIONING_END_EVENT, { detail: { placement: 'top' } })); + + expect(onPositioningEnd).toHaveBeenCalledTimes(1); + expect(container).not.toHaveAttribute('data-placement'); + }); + + it('restores the container and arrow to their previous state on dispose', async () => { + const { container, target } = createTestElements(); + const arrow = document.createElement('div'); + container.appendChild(arrow); + container.style.left = '5px'; + container.setAttribute('data-placement', 'below'); + + const manager = floatingUIPositioningEngine.create({ + container, + target, + arrow, + options: { position: 'above' }, + }); + await flushMicrotasks(); + + expect(container.style.transform).not.toBe(''); + expect(container).toHaveAttribute('data-placement', 'below-start'); + expect(container).toHaveAttribute('data-popper-placement', 'bottom-start'); + + manager.dispose(); + + expect(container.style.left).toBe('5px'); + expect(container.style.top).toBe(''); + expect(container.style.position).toBe(''); + expect(container.style.transform).toBe(''); + expect(container.style.getPropertyValue('inset')).toBe(''); + expect(container).toHaveAttribute('data-placement', 'below'); + expect(container).not.toHaveAttribute('data-popper-placement'); + }); + + it('honors an explicit strategy: "absolute"', async () => { + const { container, target } = createTestElements(); + + const manager = floatingUIPositioningEngine.create({ + container, + target, + arrow: null, + options: { strategy: 'absolute' }, + }); + await flushMicrotasks(); + + expect(computePositionMock).toHaveBeenCalledWith( + target, + container, + expect.objectContaining({ strategy: 'absolute' }), + ); + + manager.dispose(); + }); + + it('does nothing when enabled is false', async () => { + const { container, target } = createTestElements(); + + const manager = floatingUIPositioningEngine.create({ + container, + target, + arrow: null, + options: { enabled: false }, + }); + await flushMicrotasks(); + + expect(computePositionMock).not.toHaveBeenCalled(); + expect(() => manager.updatePosition()).not.toThrow(); + expect(() => manager.dispose()).not.toThrow(); + }); + + it('runs the configuration function over the merged options', async () => { + const { container, target } = createTestElements(); + const configuration = jest.fn, Parameters>( + ({ options }) => ({ ...options, position: 'above' }), + ); + const engine = createFloatingUIPositioningEngine({ configuration }); + + const manager = engine.create({ + container, + target, + arrow: null, + options: { position: 'below', autoSize: true }, + }); + await flushMicrotasks(); + + expect(configuration).toHaveBeenCalledWith( + expect.objectContaining({ + container, + arrow: null, + options: expect.objectContaining({ position: 'below', autoSize: true }), + }), + ); + expect(computePositionMock).toHaveBeenCalledWith(target, container, expect.objectContaining({ placement: 'top' })); + + manager.dispose(); + }); +}); diff --git a/packages/react-components/react-positioning/library/src/createFloatingUIPositioningEngine.ts b/packages/react-components/react-positioning/library/src/createFloatingUIPositioningEngine.ts new file mode 100644 index 00000000000000..5fc69fb6501e6f --- /dev/null +++ b/packages/react-components/react-positioning/library/src/createFloatingUIPositioningEngine.ts @@ -0,0 +1,168 @@ +import { + DATA_PLACEMENT, + DATA_POSITIONING_ESCAPED, + DATA_POSITIONING_HIDDEN, + DATA_POSITIONING_INTERSECTING, + DATA_POSITIONING_PLACEMENT, + POSITIONING_END_EVENT, +} from './constants'; +import { createPositionManager } from './createPositionManager'; +import { matchTargetSizeCssVar } from './middleware'; +import { resolvePositioningOptions } from './resolvePositioningOptions'; +import type { + OnPositioningEndEvent, + PositioningConfigurationFn, + PositioningEngine, + PositioningEngineCreateParams, + PositionManager, +} from './types'; +import { toPositioningShorthandValue } from './utils'; + +const NOOP_MANAGER: PositionManager = { + updatePosition: () => undefined, + dispose: () => undefined, +}; + +const DEFAULT_CONFIGURATION: PositioningConfigurationFn = ({ options }) => options; + +/** + * Inline styles the position manager and its middleware may write on the container. Shorthands come + * before their longhands so that restoring them in order does not wipe restored longhands. + */ +const CONTAINER_STYLE_PROPERTIES = [ + 'inset', + 'top', + 'right', + 'bottom', + 'left', + 'margin', + 'margin-top', + 'margin-right', + 'margin-bottom', + 'margin-left', + 'position', + 'transform', + 'box-sizing', + 'width', + 'height', + 'max-width', + 'max-height', + 'overflow-x', + 'overflow-y', + matchTargetSizeCssVar, +]; + +const CONTAINER_ATTRIBUTES = [ + DATA_PLACEMENT, + DATA_POSITIONING_PLACEMENT, + DATA_POSITIONING_ESCAPED, + DATA_POSITIONING_HIDDEN, + DATA_POSITIONING_INTERSECTING, +]; + +const ARROW_STYLE_PROPERTIES = ['top', 'left']; + +/** + * Records the current values of the given inline styles and attributes, and returns a function that + * restores them. + */ +function snapshotElement(element: HTMLElement, styleProperties: string[], attributes: string[] = []): () => void { + const styles = styleProperties.map(property => [property, element.style.getPropertyValue(property)] as const); + const attributeValues = attributes.map(attribute => [attribute, element.getAttribute(attribute)] as const); + + return () => { + styles.forEach(([property, value]) => { + if (value) { + element.style.setProperty(property, value); + } else { + element.style.removeProperty(property); + } + }); + attributeValues.forEach(([attribute, value]) => { + if (value === null) { + element.removeAttribute(attribute); + } else { + element.setAttribute(attribute, value); + } + }); + }; +} + +export type CreateFloatingUIPositioningEngineOptions = { + /** + * Equivalent of `PositioningConfigurationProvider` for engine consumers: lets you post-process the + * resolved options for every surface positioned by this engine. + */ + configuration?: PositioningConfigurationFn; +}; + +/** + * Creates a {@link PositioningEngine} backed by Floating UI — the same implementation that powers + * `usePositioning()` in Fluent UI React v9. + * + * Intended for consumers of `@fluentui/react-headless-components-preview` who need positioning + * features that CSS anchor positioning cannot express (`autoSize`, `flipBoundary`, + * `overflowBoundary`, virtual targets, etc.). + * + */ +export function createFloatingUIPositioningEngine( + engineOptions: CreateFloatingUIPositioningEngineOptions = {}, +): PositioningEngine { + const { configuration = DEFAULT_CONFIGURATION } = engineOptions; + + return { + create: (params: PositioningEngineCreateParams): PositionManager => { + const { container, target, arrow, options, dir = 'ltr', targetDocument = container.ownerDocument } = params; + const { enabled = true, onPositioningEnd } = options; + const isRtl = dir === 'rtl'; + + if (!enabled) { + return NOOP_MANAGER; + } + + const restoreContainer = snapshotElement(container, CONTAINER_STYLE_PROPERTIES, CONTAINER_ATTRIBUTES); + const restoreArrow = arrow ? snapshotElement(arrow, ARROW_STYLE_PROPERTIES) : undefined; + + // Top-layer surfaces (`[popover]:popover-open`, `dialog:modal`) receive `inset: 0` from the UA + // stylesheet. The manager only writes `left`/`top`, so `right`/`bottom` must be released first or + // the surface stretches across the viewport. + container.style.setProperty('inset', 'auto'); + + const handlePositioningEnd = (event: Event) => { + const { placement } = (event as OnPositioningEndEvent).detail; + container.setAttribute(DATA_PLACEMENT, toPositioningShorthandValue(placement, isRtl)); + onPositioningEnd?.(event as OnPositioningEndEvent); + }; + container.addEventListener(POSITIONING_END_EVENT, handlePositioningEnd); + + const manager = createPositionManager({ + container, + target, + arrow, + ...resolvePositioningOptions({ + container, + arrow, + options, + isRtl, + targetDocument, + configFn: configuration, + }), + }); + + return { + updatePosition: manager.updatePosition, + dispose: () => { + container.removeEventListener(POSITIONING_END_EVENT, handlePositioningEnd); + manager.dispose(); + restoreContainer(); + restoreArrow?.(); + }, + }; + }, + }; +} + +/** + * Default Floating UI {@link PositioningEngine}. See {@link createFloatingUIPositioningEngine}. + */ +export const floatingUIPositioningEngine: PositioningEngine = createFloatingUIPositioningEngine(); diff --git a/packages/react-components/react-positioning/library/src/index.ts b/packages/react-components/react-positioning/library/src/index.ts index 26430e1129759a..f59052decf151d 100644 --- a/packages/react-components/react-positioning/library/src/index.ts +++ b/packages/react-components/react-positioning/library/src/index.ts @@ -9,6 +9,8 @@ export type { CreateArrowStylesOptions } from './createArrowStyles'; export { PositioningConfigurationProvider } from './PositioningConfigurationContext'; export { usePositioning } from './usePositioning'; +export { createFloatingUIPositioningEngine, floatingUIPositioningEngine } from './createFloatingUIPositioningEngine'; +export type { CreateFloatingUIPositioningEngineOptions } from './createFloatingUIPositioningEngine'; export { usePositioningMouseTarget } from './usePositioningMouseTarget'; export { useSafeZoneArea } from './hooks/useSafeZoneArea/useSafeZoneArea'; export type { UseSafeZoneOptions } from './hooks/useSafeZoneArea/useSafeZoneArea'; @@ -24,10 +26,17 @@ export type { OffsetFunctionParam, OffsetObject, OffsetShorthand, + OnPositioningEndEvent, + OnPositioningEndEventDetail, Position, PositioningBoundary, + PositioningEngine, + PositioningEngineCreateParams, PositioningImperativeRef, + PositioningOptions, + PositioningPlacement, PositioningProps, + PositionManager, PositioningRect, PositioningShorthand, PositioningShorthandValue, diff --git a/packages/react-components/react-positioning/library/src/resolvePositioningOptions.ts b/packages/react-components/react-positioning/library/src/resolvePositioningOptions.ts new file mode 100644 index 00000000000000..828c113df5d609 --- /dev/null +++ b/packages/react-components/react-positioning/library/src/resolvePositioningOptions.ts @@ -0,0 +1,167 @@ +import { devtools } from '@floating-ui/devtools'; +import { hide as hideMiddleware, arrow as arrowMiddleware } from '@floating-ui/dom'; +import type { Middleware, Placement, Strategy } from '@floating-ui/dom'; + +import { + shift as shiftMiddleware, + flip as flipMiddleware, + coverTarget as coverTargetMiddleware, + maxSize as maxSizeMiddleware, + resetMaxSize as resetMaxSizeMiddleware, + offset as offsetMiddleware, + intersecting as intersectingMiddleware, + matchTargetSize as matchTargetSizeMiddleware, +} from './middleware'; +import type { PositioningConfigurationFn, PositioningConfigurationFnOptions, PositioningOptions } from './types'; +import { toFloatingUIPlacement, hasScrollParent, normalizeAutoSize } from './utils'; +import { devtoolsCallback } from './utils/devtools'; + +/** + * @internal + */ +export type ResolvedPositioningOptions = { + placement: Placement | undefined; + middleware: Middleware[]; + strategy: Strategy; + disableUpdateOnResize?: boolean; + useTransform?: boolean; +}; + +/** + * @internal + */ +export type ResolvePositioningOptionsParams = { + container: HTMLElement; + arrow: HTMLElement | null; + options: PositioningOptions; + isRtl: boolean; + targetDocument: Document | undefined; + configFn: PositioningConfigurationFn; +}; + +/** + * Picks the options that are forwarded to a `PositioningConfigurationFn`. Options that only make sense + * to the React layer (`enabled`, `onPositioningEnd`, `positionFixed`, `target`, `positioningRef`) are + * intentionally left out. + */ +function toConfigurationFnOptions(options: PositioningOptions): PositioningConfigurationFnOptions { + const { + align, + arrowPadding, + autoSize, + coverTarget, + disableUpdateOnResize, + flipBoundary, + offset, + overflowBoundary, + pinned, + position, + // eslint-disable-next-line @typescript-eslint/naming-convention + unstable_disableTether, + strategy, + overflowBoundaryPadding, + fallbackPositions, + useTransform, + matchTargetSize, + shiftToCoverTarget, + } = options; + + return { + autoSize, + disableUpdateOnResize, + matchTargetSize, + offset, + strategy, + coverTarget, + flipBoundary, + overflowBoundary, + useTransform, + overflowBoundaryPadding, + pinned, + arrowPadding, + align, + fallbackPositions, + shiftToCoverTarget, + position, + // eslint-disable-next-line @typescript-eslint/naming-convention + unstable_disableTether, + }; +} + +/** + * Translates Fluent positioning options into a Floating UI placement + middleware chain. + * + * Pure with respect to React: it can be called from a hook (see `usePositioningOptions`) or from an + * imperative engine (see `createFloatingUIPositioningEngine`). + * + * @internal + */ +export function resolvePositioningOptions(params: ResolvePositioningOptionsParams): ResolvedPositioningOptions { + const { container, arrow, options, isRtl, targetDocument, configFn } = params; + const { + // eslint-disable-next-line @typescript-eslint/no-deprecated + positionFixed, + } = options; + + const hasScrollableElement = hasScrollParent(container); + + const optionsAfterEnhancement = configFn({ container, arrow, options: toConfigurationFnOptions(options) }); + const { + autoSize, + disableUpdateOnResize, + matchTargetSize, + offset, + coverTarget, + flipBoundary, + overflowBoundary, + useTransform, + overflowBoundaryPadding, + pinned, + position, + arrowPadding, + strategy, + align, + fallbackPositions, + shiftToCoverTarget, + // eslint-disable-next-line @typescript-eslint/naming-convention + unstable_disableTether, + } = optionsAfterEnhancement; + const normalizedAutoSize = normalizeAutoSize(autoSize); + + const middleware = [ + normalizedAutoSize && resetMaxSizeMiddleware(normalizedAutoSize), + matchTargetSize && matchTargetSizeMiddleware(), + offset && offsetMiddleware(offset), + coverTarget && coverTargetMiddleware(), + !pinned && flipMiddleware({ container, flipBoundary, hasScrollableElement, isRtl, fallbackPositions }), + shiftMiddleware({ + container, + hasScrollableElement, + overflowBoundary, + disableTether: unstable_disableTether, + overflowBoundaryPadding, + isRtl, + shiftToCoverTarget, + }), + normalizedAutoSize && + maxSizeMiddleware(normalizedAutoSize, { container, overflowBoundary, overflowBoundaryPadding, isRtl }), + intersectingMiddleware(), + arrow && arrowMiddleware({ element: arrow, padding: arrowPadding }), + hideMiddleware({ strategy: 'referenceHidden' }), + hideMiddleware({ strategy: 'escaped' }), + process.env.NODE_ENV !== 'production' && + targetDocument && + devtools(targetDocument, devtoolsCallback(optionsAfterEnhancement)), + ].filter(Boolean) as Middleware[]; + + const placement = toFloatingUIPlacement(align, position, isRtl); + + return { + placement, + middleware, + strategy: strategy ?? (positionFixed ? 'fixed' : 'absolute'), + + disableUpdateOnResize, + useTransform, + }; +} diff --git a/packages/react-components/react-positioning/library/src/types.ts b/packages/react-components/react-positioning/library/src/types.ts index 762d3153be86f9..2a7384bd96f059 100644 --- a/packages/react-components/react-positioning/library/src/types.ts +++ b/packages/react-components/react-positioning/library/src/types.ts @@ -66,13 +66,54 @@ export type OffsetFunctionParam = { export type TargetElement = HTMLElement | PositioningVirtualElement; /** - * @internal + * Handle to a running positioning instance that lives outside of the React lifecycle. */ export interface PositionManager { updatePosition: () => void; dispose: () => void; } +/** + * Arguments a {@link PositioningEngine} receives when it is asked to start positioning a surface. + */ +export type PositioningEngineCreateParams = { + /** The positioned element */ + container: HTMLElement; + /** Element (or virtual element) that the container is anchored to */ + target: TargetElement; + /** Arrow that points from the container to the target */ + arrow: HTMLElement | null; + /** Fully merged positioning options */ + options: PositioningOptions; + /** Text direction of the surface; defaults to `ltr` */ + dir?: 'ltr' | 'rtl'; + /** Document the surface is rendered into; defaults to `container.ownerDocument` */ + targetDocument?: Document; +}; + +/** + * A pluggable positioning implementation. + * + * Engines are plain objects, not hooks: `create` is invoked imperatively (typically from a layout + * effect) once the container and target elements are known, and the returned {@link PositionManager} + * is disposed when they change or unmount. An engine can therefore be supplied through props or + * context without breaking the rules of hooks. + * + * Contract for engine authors — `create` must: + * - leave the container fully positioned, including releasing any UA-provided `inset` when the + * surface is in the top layer (`[popover]`, `dialog:modal`); + * - keep the container's `data-placement` attribute current with the resolved logical placement, as + * a {@link PositioningShorthandValue} (e.g. `above-start`, `after-top`); + * - invoke `options.onPositioningEnd` after each update, if provided; + * - in `dispose`, release listeners and observers, and restore the inline styles and attributes it + * wrote on the container and arrow to their previous values, so that another + * positioner (e.g. CSS anchor positioning) can take over a clean element. `dispose` also runs right + * before `create` is called again with new options, so both must be synchronous. + */ +export interface PositioningEngine { + create: (params: PositioningEngineCreateParams) => PositionManager; +} + export interface UsePositioningReturn { // React refs are supposed to be contravariant // (allows a more general type to be passed rather than a more specific one) diff --git a/packages/react-components/react-positioning/library/src/usePositioningOptions.ts b/packages/react-components/react-positioning/library/src/usePositioningOptions.ts index ef8719797caca0..e1a1a64d4eb77f 100644 --- a/packages/react-components/react-positioning/library/src/usePositioningOptions.ts +++ b/packages/react-components/react-positioning/library/src/usePositioningOptions.ts @@ -1,36 +1,25 @@ 'use client'; -import { devtools } from '@floating-ui/devtools'; -import { hide as hideMiddleware, arrow as arrowMiddleware } from '@floating-ui/dom'; -import type { Middleware, Placement, Strategy } from '@floating-ui/dom'; import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; import * as React from 'react'; -import { - shift as shiftMiddleware, - flip as flipMiddleware, - coverTarget as coverTargetMiddleware, - maxSize as maxSizeMiddleware, - resetMaxSize as resetMaxSizeMiddleware, - offset as offsetMiddleware, - intersecting as intersectingMiddleware, - matchTargetSize as matchTargetSizeMiddleware, -} from './middleware'; -import type { PositioningConfigurationFn, PositioningConfigurationFnOptions, PositioningOptions } from './types'; -import { toFloatingUIPlacement, hasScrollParent, normalizeAutoSize } from './utils'; -import { devtoolsCallback } from './utils/devtools'; +import type { PositioningOptions } from './types'; import { usePositioningConfiguration } from './PositioningConfigurationContext'; +import { resolvePositioningOptions } from './resolvePositioningOptions'; +import type { ResolvedPositioningOptions } from './resolvePositioningOptions'; /** - * This is redundant and exists only to manage React dependencies properly & avoid leaking individual options to the - * scope of `usePositioningOptions`. - * * @internal */ -function usePositioningConfigFn( - configFn: PositioningConfigurationFn, +export function usePositioningOptions( options: PositioningOptions, -): (container: HTMLElement, arrow: HTMLElement | null) => PositioningConfigurationFnOptions { +): (container: HTMLElement, arrow: HTMLElement | null) => ResolvedPositioningOptions { + const { dir, targetDocument } = useFluent(); + const isRtl = dir === 'rtl'; + const configFn = usePositioningConfiguration(); + + // Individual options are listed as dependencies (rather than the `options` object) so that a new + // options object with the same values does not recreate the position manager. const { align, arrowPadding, @@ -50,144 +39,62 @@ function usePositioningConfigFn( useTransform, matchTargetSize, shiftToCoverTarget, + // eslint-disable-next-line @typescript-eslint/no-deprecated + positionFixed, } = options; return React.useCallback( - (container: HTMLElement, arrow: HTMLElement | null) => { - return configFn({ + (container: HTMLElement, arrow: HTMLElement | null) => + resolvePositioningOptions({ container, arrow, + isRtl, + targetDocument, + configFn, options: { + align, + arrowPadding, autoSize, - disableUpdateOnResize, - matchTargetSize, - offset, - strategy, coverTarget, + disableUpdateOnResize, flipBoundary, + offset, overflowBoundary, - useTransform, - overflowBoundaryPadding, pinned, - arrowPadding, - align, - fallbackPositions, - shiftToCoverTarget, position, // eslint-disable-next-line @typescript-eslint/naming-convention unstable_disableTether, + strategy, + overflowBoundaryPadding, + fallbackPositions, + useTransform, + matchTargetSize, + shiftToCoverTarget, + positionFixed, }, - }); - }, + }), [ + isRtl, + targetDocument, + configFn, + align, + arrowPadding, autoSize, - disableUpdateOnResize, - matchTargetSize, - offset, - strategy, coverTarget, + disableUpdateOnResize, flipBoundary, + offset, overflowBoundary, - useTransform, - overflowBoundaryPadding, pinned, - arrowPadding, - align, - fallbackPositions, - shiftToCoverTarget, position, unstable_disableTether, - configFn, + strategy, + overflowBoundaryPadding, + fallbackPositions, + useTransform, + matchTargetSize, + shiftToCoverTarget, + positionFixed, ], ); } - -/** - * @internal - */ -export function usePositioningOptions(options: PositioningOptions): ( - container: HTMLElement, - arrow: HTMLElement | null, -) => { - placement: Placement | undefined; - middleware: Middleware[]; - strategy: Strategy; - disableUpdateOnResize?: boolean; - useTransform?: boolean; -} { - const { dir, targetDocument } = useFluent(); - const isRtl = dir === 'rtl'; - - const configFn = usePositioningConfigFn(usePositioningConfiguration(), options); - const { - // eslint-disable-next-line @typescript-eslint/no-deprecated - positionFixed, - } = options; - - return React.useCallback( - (container: HTMLElement, arrow: HTMLElement | null) => { - const hasScrollableElement = hasScrollParent(container); - - const optionsAfterEnhancement = configFn(container, arrow); - const { - autoSize, - disableUpdateOnResize, - matchTargetSize, - offset, - coverTarget, - flipBoundary, - overflowBoundary, - useTransform, - overflowBoundaryPadding, - pinned, - position, - arrowPadding, - strategy, - align, - fallbackPositions, - shiftToCoverTarget, - // eslint-disable-next-line @typescript-eslint/naming-convention - unstable_disableTether, - } = optionsAfterEnhancement; - const normalizedAutoSize = normalizeAutoSize(autoSize); - - const middleware = [ - normalizedAutoSize && resetMaxSizeMiddleware(normalizedAutoSize), - matchTargetSize && matchTargetSizeMiddleware(), - offset && offsetMiddleware(offset), - coverTarget && coverTargetMiddleware(), - !pinned && flipMiddleware({ container, flipBoundary, hasScrollableElement, isRtl, fallbackPositions }), - shiftMiddleware({ - container, - hasScrollableElement, - overflowBoundary, - disableTether: unstable_disableTether, - overflowBoundaryPadding, - isRtl, - shiftToCoverTarget, - }), - normalizedAutoSize && - maxSizeMiddleware(normalizedAutoSize, { container, overflowBoundary, overflowBoundaryPadding, isRtl }), - intersectingMiddleware(), - arrow && arrowMiddleware({ element: arrow, padding: arrowPadding }), - hideMiddleware({ strategy: 'referenceHidden' }), - hideMiddleware({ strategy: 'escaped' }), - process.env.NODE_ENV !== 'production' && - targetDocument && - devtools(targetDocument, devtoolsCallback(optionsAfterEnhancement)), - ].filter(Boolean) as Middleware[]; - - const placement = toFloatingUIPlacement(align, position, isRtl); - - return { - placement, - middleware, - strategy: strategy ?? positionFixed ? ('fixed' as const) : ('absolute' as const), - - disableUpdateOnResize, - useTransform, - }; - }, - [configFn, isRtl, targetDocument, positionFixed], - ); -} diff --git a/packages/react-components/react-positioning/library/src/utils/index.ts b/packages/react-components/react-positioning/library/src/utils/index.ts index 04feadbc94831b..674f26bf85ba80 100644 --- a/packages/react-components/react-positioning/library/src/utils/index.ts +++ b/packages/react-components/react-positioning/library/src/utils/index.ts @@ -7,6 +7,7 @@ export { mergeArrowOffset } from './mergeArrowOffset'; export { toFloatingUIPadding } from './toFloatingUIPadding'; export { toFloatingUIPlacement } from './toFloatingUIPlacement'; export { fromFloatingUIPlacement } from './fromFloatingUIPlacement'; +export { toPositioningShorthandValue } from './toPositioningShorthandValue'; export { resolvePositioningShorthand } from './resolvePositioningShorthand'; export { useCallbackRef } from './useCallbackRef'; export { debounce } from './debounce'; diff --git a/packages/react-components/react-positioning/library/src/utils/toPositioningShorthandValue.test.ts b/packages/react-components/react-positioning/library/src/utils/toPositioningShorthandValue.test.ts new file mode 100644 index 00000000000000..066da3c022440c --- /dev/null +++ b/packages/react-components/react-positioning/library/src/utils/toPositioningShorthandValue.test.ts @@ -0,0 +1,30 @@ +import { toPositioningShorthandValue } from './toPositioningShorthandValue'; + +describe('toPositioningShorthandValue', () => { + it.each([ + ['top', 'above'], + ['top-start', 'above-start'], + ['top-end', 'above-end'], + ['bottom', 'below'], + ['bottom-start', 'below-start'], + ['bottom-end', 'below-end'], + ['left', 'before'], + ['left-start', 'before-top'], + ['left-end', 'before-bottom'], + ['right', 'after'], + ['right-start', 'after-top'], + ['right-end', 'after-bottom'], + ] as const)('maps %s to %s in ltr', (placement, expected) => { + expect(toPositioningShorthandValue(placement, false)).toBe(expected); + }); + + it.each([ + ['left', 'after'], + ['left-start', 'after-top'], + ['right', 'before'], + ['right-end', 'before-bottom'], + ['top-start', 'above-start'], + ] as const)('maps %s to %s in rtl', (placement, expected) => { + expect(toPositioningShorthandValue(placement, true)).toBe(expected); + }); +}); diff --git a/packages/react-components/react-positioning/library/src/utils/toPositioningShorthandValue.ts b/packages/react-components/react-positioning/library/src/utils/toPositioningShorthandValue.ts new file mode 100644 index 00000000000000..cf5e37862a36ae --- /dev/null +++ b/packages/react-components/react-positioning/library/src/utils/toPositioningShorthandValue.ts @@ -0,0 +1,22 @@ +import type { Position, PositioningPlacement, PositioningShorthandValue } from '../types'; +import { fromFloatingUIPlacement } from './fromFloatingUIPlacement'; + +const RTL_POSITION_SWAP: Partial> = { before: 'after', after: 'before' }; + +/** + * Maps a physical Floating UI placement to the logical shorthand vocabulary (`above-start`, + * `after-top`, ...). In RTL the horizontal sides are swapped so `before`/`after` stay logical. + */ +export function toPositioningShorthandValue( + placement: PositioningPlacement, + isRtl: boolean, +): PositioningShorthandValue { + const { position: physicalPosition, alignment } = fromFloatingUIPlacement(placement); + const position = (isRtl && RTL_POSITION_SWAP[physicalPosition]) || physicalPosition; + + if (!alignment || alignment === 'center') { + return position; + } + + return `${position}-${alignment}` as PositioningShorthandValue; +}