From ddc3692d2e46b4a0073225dc6e9ca82b4b375cd9 Mon Sep 17 00:00:00 2001 From: Sean Monahan Date: Fri, 7 Aug 2026 17:52:25 +0000 Subject: [PATCH 01/11] feat(react-positioning): add imperative manager Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...-7e9f842b-2838-4efe-b8df-7fdac36d63dd.json | 7 + .../library/etc/react-positioning.api.md | 23 ++ .../src/createPositioningManager.test.ts | 122 +++++++++++ .../library/src/createPositioningManager.ts | 71 +++++++ .../react-positioning/library/src/index.ts | 4 + .../library/src/middleware/flip.ts | 14 +- .../src/resolvePositioningOptions.test.ts | 139 ++++++++++++ .../library/src/resolvePositioningOptions.ts | 128 +++++++++++ .../library/src/types.test.ts | 27 ++- .../react-positioning/library/src/types.ts | 56 ++++- .../library/src/usePositioning.test.tsx | 62 +++++- .../library/src/usePositioningOptions.ts | 201 +++++------------- 12 files changed, 691 insertions(+), 163 deletions(-) create mode 100644 change/@fluentui-react-positioning-7e9f842b-2838-4efe-b8df-7fdac36d63dd.json create mode 100644 packages/react-components/react-positioning/library/src/createPositioningManager.test.ts create mode 100644 packages/react-components/react-positioning/library/src/createPositioningManager.ts create mode 100644 packages/react-components/react-positioning/library/src/resolvePositioningOptions.test.ts create mode 100644 packages/react-components/react-positioning/library/src/resolvePositioningOptions.ts diff --git a/change/@fluentui-react-positioning-7e9f842b-2838-4efe-b8df-7fdac36d63dd.json b/change/@fluentui-react-positioning-7e9f842b-2838-4efe-b8df-7fdac36d63dd.json new file mode 100644 index 00000000000000..ddc4666fd74f47 --- /dev/null +++ b/change/@fluentui-react-positioning-7e9f842b-2838-4efe-b8df-7fdac36d63dd.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: add an imperative unstable positioning manager factory", + "packageName": "@fluentui/react-positioning", + "email": "email not defined", + "dependentChangeType": "patch" +} 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..b62541d69fb44a 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 @@ -35,9 +35,23 @@ export type CreateArrowStylesOptions = { // @public @deprecated export function createSlideStyles(mainAxis: number): GriffelStyle; +// @public (undocumented) +export function createPositioningManager_unstable(options: CreatePositioningManagerOptions_unstable): PositioningManager_unstable; + // @public export function createVirtualElementFromClick(nativeEvent: MouseEvent): PositioningVirtualElement; +// @public (undocumented) +export interface CreatePositioningManagerOptions_unstable extends Omit, Pick { + container: HTMLElement; + target: TargetElement; + arrow?: HTMLElement | null; + dir?: 'ltr' | 'rtl'; + targetDocument?: Document; + unstable_disableShift?: boolean; + unstable_flipFallbackStrategy?: PositioningFlipFallbackStrategy_unstable; +} + // @internal export function mergeArrowOffset(userOffset: Offset | undefined | null, arrowHeight: number): Offset; @@ -89,12 +103,21 @@ export type PositioningConfigurationFnOptions = Omit; +// @public (undocumented) +export type PositioningFlipFallbackStrategy_unstable = 'bestFit' | 'initialPlacement'; + // @public (undocumented) export type PositioningImperativeRef = { updatePosition: () => void; setTarget: (target: TargetElement | null) => void; }; +// @public (undocumented) +export interface PositioningManager_unstable { + updatePosition: () => void; + dispose: () => void; +} + // @public export interface PositioningProps extends Pick { positioningRef?: React_2.Ref; diff --git a/packages/react-components/react-positioning/library/src/createPositioningManager.test.ts b/packages/react-components/react-positioning/library/src/createPositioningManager.test.ts new file mode 100644 index 00000000000000..c653793a957bad --- /dev/null +++ b/packages/react-components/react-positioning/library/src/createPositioningManager.test.ts @@ -0,0 +1,122 @@ +import { createPositionManager } from './createPositionManager'; +import { createPositioningManager_unstable } from './createPositioningManager'; +import { POSITIONING_END_EVENT } from './constants'; +import { resolvePositioningOptions } from './resolvePositioningOptions'; + +jest.mock('./createPositionManager', () => ({ + createPositionManager: jest.fn(), +})); + +jest.mock('./resolvePositioningOptions', () => ({ + resolvePositioningOptions: jest.fn(() => ({ + placement: 'bottom', + middleware: [], + strategy: 'absolute', + })), +})); + +const createPositionManagerMock = createPositionManager as jest.MockedFunction; +const resolvePositioningOptionsMock = resolvePositioningOptions as jest.MockedFunction; + +describe('createPositioningManager_unstable', () => { + beforeEach(() => { + createPositionManagerMock.mockReset(); + resolvePositioningOptionsMock.mockClear(); + }); + + it('creates a positioning manager from the shared resolver output', () => { + const updatePosition = jest.fn(); + const dispose = jest.fn(); + const container = document.createElement('div'); + const target = document.createElement('button'); + const arrow = document.createElement('div'); + + createPositionManagerMock.mockReturnValue({ updatePosition, dispose }); + + const manager = createPositioningManager_unstable({ + container, + target, + arrow, + dir: 'rtl', + targetDocument: document, + align: 'top', + position: 'after', + unstable_disableShift: true, + unstable_flipFallbackStrategy: 'initialPlacement', + }); + + expect(resolvePositioningOptionsMock).toHaveBeenCalledWith( + expect.objectContaining({ + align: 'top', + arrow, + container, + isRtl: true, + position: 'after', + targetDocument: document, + unstable_disableShift: true, + unstable_flipFallbackStrategy: 'initialPlacement', + }), + ); + expect(createPositionManagerMock).toHaveBeenCalledWith({ + arrow, + container, + target, + middleware: [], + placement: 'bottom', + strategy: 'absolute', + }); + + manager.updatePosition(); + expect(updatePosition).toHaveBeenCalledTimes(1); + + manager.dispose(); + expect(dispose).toHaveBeenCalledTimes(1); + }); + + it('calls onPositioningEnd through the manager event path and removes the listener on dispose', () => { + const container = document.createElement('div'); + const target = document.createElement('button'); + const onPositioningEnd = jest.fn(); + const dispose = jest.fn(); + + createPositionManagerMock.mockImplementation(({ container: currentContainer }) => ({ + updatePosition: () => { + currentContainer.dispatchEvent( + new CustomEvent(POSITIONING_END_EVENT, { + detail: { placement: 'bottom', escaped: false, referenceHidden: false }, + }), + ); + }, + dispose, + })); + + const manager = createPositioningManager_unstable({ + container, + target, + onPositioningEnd, + }); + + manager.updatePosition(); + expect(onPositioningEnd).toHaveBeenCalledTimes(1); + + manager.dispose(); + manager.updatePosition(); + + expect(onPositioningEnd).toHaveBeenCalledTimes(1); + expect(dispose).toHaveBeenCalledTimes(1); + }); + + it('returns a noop manager when disabled', () => { + const manager = createPositioningManager_unstable({ + container: document.createElement('div'), + target: document.createElement('button'), + enabled: false, + }); + + manager.updatePosition(); + manager.dispose(); + + expect(resolvePositioningOptionsMock).not.toHaveBeenCalled(); + expect(createPositionManagerMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react-components/react-positioning/library/src/createPositioningManager.ts b/packages/react-components/react-positioning/library/src/createPositioningManager.ts new file mode 100644 index 00000000000000..24b014453167bd --- /dev/null +++ b/packages/react-components/react-positioning/library/src/createPositioningManager.ts @@ -0,0 +1,71 @@ +'use client'; + +import { canUseDOM } from '@fluentui/react-utilities'; + +import { POSITIONING_END_EVENT } from './constants'; +import { createPositionManager } from './createPositionManager'; +import { resolvePositioningOptions } from './resolvePositioningOptions'; +import type { + CreatePositioningManagerOptions_unstable, + OnPositioningEndEvent, + PositioningManager_unstable, +} from './types'; + +const createNoopPositioningManager = (): PositioningManager_unstable => ({ + updatePosition: () => undefined, + dispose: () => undefined, +}); + +export function createPositioningManager_unstable( + options: CreatePositioningManagerOptions_unstable, +): PositioningManager_unstable { + const { + container, + target, + arrow = null, + dir = 'ltr', + targetDocument = container.ownerDocument, + onPositioningEnd, + enabled = true, + unstable_disableShift, + unstable_flipFallbackStrategy, + ...positioningOptions + } = options; + + if (!enabled || !canUseDOM()) { + return createNoopPositioningManager(); + } + + const handlePositioningEnd = (event: Event) => { + onPositioningEnd?.(event as OnPositioningEndEvent); + }; + + if (onPositioningEnd) { + container.addEventListener(POSITIONING_END_EVENT, handlePositioningEnd); + } + + const manager = createPositionManager({ + container, + target, + arrow, + ...resolvePositioningOptions({ + ...positioningOptions, + container, + arrow, + isRtl: dir === 'rtl', + targetDocument, + unstable_disableShift, + unstable_flipFallbackStrategy, + }), + }); + + return { + updatePosition: () => manager.updatePosition(), + dispose: () => { + if (onPositioningEnd) { + container.removeEventListener(POSITIONING_END_EVENT, handlePositioningEnd); + } + manager.dispose(); + }, + }; +} diff --git a/packages/react-components/react-positioning/library/src/index.ts b/packages/react-components/react-positioning/library/src/index.ts index 26430e1129759a..403a4783d002a0 100644 --- a/packages/react-components/react-positioning/library/src/index.ts +++ b/packages/react-components/react-positioning/library/src/index.ts @@ -1,4 +1,5 @@ export { createVirtualElementFromClick } from './createVirtualElementFromClick'; +export { createPositioningManager_unstable } from './createPositioningManager'; export { usePositioningSlideDirection } from './usePositioningSlideDirection'; export { POSITIONING_SLIDE_DIRECTION_VAR_X, POSITIONING_SLIDE_DIRECTION_VAR_Y } from './constants'; export { createArrowHeightStyles, createArrowStyles } from './createArrowStyles'; @@ -19,6 +20,7 @@ export type { AutoSize, // eslint-disable-next-line @typescript-eslint/no-deprecated Boundary, + CreatePositioningManagerOptions_unstable, Offset, OffsetFunction, OffsetFunctionParam, @@ -35,4 +37,6 @@ export type { SetVirtualMouseTarget, PositioningConfigurationFn, PositioningConfigurationFnOptions, + PositioningFlipFallbackStrategy_unstable, + PositioningManager_unstable, } from './types'; diff --git a/packages/react-components/react-positioning/library/src/middleware/flip.ts b/packages/react-components/react-positioning/library/src/middleware/flip.ts index 4b59eb16939e67..34c14549c06330 100644 --- a/packages/react-components/react-positioning/library/src/middleware/flip.ts +++ b/packages/react-components/react-positioning/library/src/middleware/flip.ts @@ -1,16 +1,24 @@ import type { Placement, Middleware } from '@floating-ui/dom'; import { flip as baseFlip } from '@floating-ui/dom'; -import type { PositioningOptions } from '../types'; +import type { PositioningFlipFallbackStrategy_unstable, PositioningOptions } from '../types'; import { getBoundary, resolvePositioningShorthand, toFloatingUIPlacement } from '../utils/index'; export interface FlipMiddlewareOptions extends Pick { hasScrollableElement?: boolean; container: HTMLElement | null; isRtl?: boolean; + fallbackStrategy?: PositioningFlipFallbackStrategy_unstable; } export function flip(options: FlipMiddlewareOptions): Middleware { - const { hasScrollableElement, flipBoundary, container, fallbackPositions = [], isRtl } = options; + const { + hasScrollableElement, + flipBoundary, + container, + fallbackPositions = [], + isRtl, + fallbackStrategy = 'bestFit', + } = options; const fallbackPlacements = fallbackPositions.reduce((acc, shorthand) => { const { position, align } = resolvePositioningShorthand(shorthand); @@ -24,7 +32,7 @@ export function flip(options: FlipMiddlewareOptions): Middleware { return baseFlip({ ...(hasScrollableElement && { boundary: 'clippingAncestors' }), ...(flipBoundary && { altBoundary: true, boundary: getBoundary(container, flipBoundary) }), - fallbackStrategy: 'bestFit', + fallbackStrategy, ...(fallbackPlacements.length && { fallbackPlacements }), }); } diff --git a/packages/react-components/react-positioning/library/src/resolvePositioningOptions.test.ts b/packages/react-components/react-positioning/library/src/resolvePositioningOptions.test.ts new file mode 100644 index 00000000000000..77f5b845447f9c --- /dev/null +++ b/packages/react-components/react-positioning/library/src/resolvePositioningOptions.test.ts @@ -0,0 +1,139 @@ +import { arrow as arrowMiddleware, hide as hideMiddleware } from '@floating-ui/dom'; +import { devtools } from '@floating-ui/devtools'; + +import { coverTarget, flip, intersecting, matchTargetSize, maxSize, offset, resetMaxSize, shift } from './middleware'; +import { resolvePositioningOptions } from './resolvePositioningOptions'; +import type { PositioningConfigurationFn } from './types'; + +jest.mock('@floating-ui/dom', () => ({ + arrow: jest.fn(() => ({ name: 'arrow' })), + hide: jest.fn(({ strategy }: { strategy: string }) => ({ name: `hide:${strategy}` })), +})); + +jest.mock('@floating-ui/devtools', () => ({ + devtools: jest.fn(() => ({ name: 'devtools' })), +})); + +jest.mock('./middleware', () => ({ + coverTarget: jest.fn(() => ({ name: 'coverTarget' })), + flip: jest.fn(() => ({ name: 'flip' })), + intersecting: jest.fn(() => ({ name: 'intersecting' })), + matchTargetSize: jest.fn(() => ({ name: 'matchTargetSize' })), + maxSize: jest.fn(() => ({ name: 'maxSize' })), + offset: jest.fn(() => ({ name: 'offset' })), + resetMaxSize: jest.fn(() => ({ name: 'resetMaxSize' })), + shift: jest.fn(() => ({ name: 'shift' })), +})); + +const arrowMiddlewareMock = arrowMiddleware as jest.MockedFunction; +const coverTargetMock = coverTarget as jest.MockedFunction; +const devtoolsMock = devtools as jest.MockedFunction; +const flipMock = flip as jest.MockedFunction; +const hideMiddlewareMock = hideMiddleware as jest.MockedFunction; +const intersectingMock = intersecting as jest.MockedFunction; +const matchTargetSizeMock = matchTargetSize as jest.MockedFunction; +const maxSizeMock = maxSize as jest.MockedFunction; +const offsetMock = offset as jest.MockedFunction; +const resetMaxSizeMock = resetMaxSize as jest.MockedFunction; +const shiftMock = shift as jest.MockedFunction; + +describe('resolvePositioningOptions', () => { + const container = document.createElement('div'); + const arrow = document.createElement('div'); + + beforeEach(() => { + arrowMiddlewareMock.mockClear(); + coverTargetMock.mockClear(); + devtoolsMock.mockClear(); + flipMock.mockClear(); + hideMiddlewareMock.mockClear(); + intersectingMock.mockClear(); + matchTargetSizeMock.mockClear(); + maxSizeMock.mockClear(); + offsetMock.mockClear(); + resetMaxSizeMock.mockClear(); + shiftMock.mockClear(); + }); + + it('uses the shared default middleware chain', () => { + const result = resolvePositioningOptions({ + container, + arrow: null, + isRtl: false, + targetDocument: document, + align: 'start', + position: 'above', + offset: 4, + }); + + expect(result.placement).toBe('top-start'); + expect(result.strategy).toBe('absolute'); + expect(flipMock).toHaveBeenCalledWith( + expect.objectContaining({ + container, + fallbackStrategy: 'bestFit', + fallbackPositions: undefined, + isRtl: false, + }), + ); + expect(shiftMock).toHaveBeenCalledWith( + expect.objectContaining({ + container, + isRtl: false, + }), + ); + expect(offsetMock).toHaveBeenCalledWith(4); + expect(intersectingMock).toHaveBeenCalledTimes(1); + expect(hideMiddlewareMock).toHaveBeenNthCalledWith(1, { strategy: 'referenceHidden' }); + expect(hideMiddlewareMock).toHaveBeenNthCalledWith(2, { strategy: 'escaped' }); + expect(devtoolsMock).toHaveBeenCalledTimes(1); + }); + + it('supports internal anchor-positioning overrides without changing hook defaults', () => { + const positioningConfiguration: PositioningConfigurationFn = ({ options }) => ({ + ...options, + coverTarget: true, + matchTargetSize: 'width', + }); + + const result = resolvePositioningOptions({ + container, + arrow, + isRtl: true, + targetDocument: document, + positioningConfiguration, + position: 'after', + align: 'top', + strategy: 'fixed', + autoSize: 'height', + flipBoundary: document.body, + overflowBoundary: document.body, + arrowPadding: 8, + unstable_disableShift: true, + unstable_flipFallbackStrategy: 'initialPlacement', + }); + + expect(result.placement).toBe('left-start'); + expect(result.strategy).toBe('fixed'); + expect(flipMock).toHaveBeenCalledWith( + expect.objectContaining({ + fallbackStrategy: 'initialPlacement', + flipBoundary: document.body, + isRtl: true, + }), + ); + expect(shiftMock).not.toHaveBeenCalled(); + expect(coverTargetMock).toHaveBeenCalledTimes(1); + expect(matchTargetSizeMock).toHaveBeenCalledTimes(1); + expect(resetMaxSizeMock).toHaveBeenCalledTimes(1); + expect(maxSizeMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + container, + isRtl: true, + overflowBoundary: document.body, + }), + ); + expect(arrowMiddlewareMock).toHaveBeenCalledWith({ element: arrow, padding: 8 }); + }); +}); 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..b62838e0c6431e --- /dev/null +++ b/packages/react-components/react-positioning/library/src/resolvePositioningOptions.ts @@ -0,0 +1,128 @@ +'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 { + 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, + PositioningFlipFallbackStrategy_unstable, + PositioningOptions, +} from './types'; +import { toFloatingUIPlacement, hasScrollParent, normalizeAutoSize } from './utils'; +import { devtoolsCallback } from './utils/devtools'; + +const defaultPositioningConfiguration: PositioningConfigurationFn = ({ options }) => options; + +type ResolvePositioningOptionsInput = PositioningConfigurationFnOptions & { + container: HTMLElement; + arrow: HTMLElement | null; + targetDocument?: Document; + isRtl: boolean; + positioningConfiguration?: PositioningConfigurationFn; + positionFixed?: PositioningOptions['positionFixed']; + unstable_disableShift?: boolean; + unstable_flipFallbackStrategy?: PositioningFlipFallbackStrategy_unstable; +}; + +export type ResolvedPositioningOptions = { + placement: Placement | undefined; + middleware: Middleware[]; + strategy: Strategy; + disableUpdateOnResize?: boolean; + useTransform?: boolean; +}; + +export function resolvePositioningOptions(input: ResolvePositioningOptionsInput): ResolvedPositioningOptions { + const { + container, + arrow, + targetDocument, + isRtl, + positioningConfiguration = defaultPositioningConfiguration, + positionFixed, + unstable_disableShift = false, + unstable_flipFallbackStrategy = 'bestFit', + ...options + } = input; + + const hasScrollableElement = hasScrollParent(container); + const optionsAfterEnhancement = positioningConfiguration({ container, arrow, 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, + fallbackStrategy: unstable_flipFallbackStrategy, + }), + !unstable_disableShift && + 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, + }; +} diff --git a/packages/react-components/react-positioning/library/src/types.test.ts b/packages/react-components/react-positioning/library/src/types.test.ts index 1d74fd5116c473..282107b058e80e 100644 --- a/packages/react-components/react-positioning/library/src/types.test.ts +++ b/packages/react-components/react-positioning/library/src/types.test.ts @@ -1,6 +1,10 @@ import * as React from 'react'; import type { OnPositioningEndEvent } from './types'; -import type { PositioningProps } from './types'; +import type { + CreatePositioningManagerOptions_unstable, + PositioningManager_unstable, + PositioningProps, +} from './types'; describe('PositioningProps', () => { it('should not break API', () => { @@ -54,4 +58,25 @@ describe('PositioningProps', () => { expect(props.onPositioningEnd).toBeDefined(); }); + + it('supports createPositioningManager_unstable options', () => { + const options: CreatePositioningManagerOptions_unstable = { + container: document.createElement('div'), + target: document.createElement('button'), + dir: 'rtl', + unstable_disableShift: true, + unstable_flipFallbackStrategy: 'initialPlacement', + }; + + expect(options).toBeTruthy; + }); + + it('supports the unstable imperative manager contract', () => { + const manager: PositioningManager_unstable = { + updatePosition: () => undefined, + dispose: () => undefined, + }; + + expect(manager).toBeTruthy; + }); }); diff --git a/packages/react-components/react-positioning/library/src/types.ts b/packages/react-components/react-positioning/library/src/types.ts index 762d3153be86f9..d48a640edd44f2 100644 --- a/packages/react-components/react-positioning/library/src/types.ts +++ b/packages/react-components/react-positioning/library/src/types.ts @@ -66,13 +66,23 @@ export type OffsetFunctionParam = { export type TargetElement = HTMLElement | PositioningVirtualElement; /** - * @internal + * Floating UI flip fallback strategies supported by the imperative positioning API. + */ +export type PositioningFlipFallbackStrategy_unstable = 'bestFit' | 'initialPlacement'; + +/** + * Public imperative positioning manager contract. */ -export interface PositionManager { +export interface PositioningManager_unstable { updatePosition: () => void; dispose: () => void; } +/** + * @internal + */ +export interface PositionManager extends PositioningManager_unstable {} + export interface UsePositioningReturn { // React refs are supposed to be contravariant // (allows a more general type to be passed rather than a more specific one) @@ -298,6 +308,48 @@ export interface PositioningProps target?: TargetElement | null; } +export interface CreatePositioningManagerOptions_unstable + extends Omit, + Pick { + /** + * The positioned element. + */ + container: HTMLElement; + + /** + * Element that the container will be anchored to. + */ + target: TargetElement; + + /** + * Arrow that points from the container to the target. + */ + arrow?: HTMLElement | null; + + /** + * Logical direction used to resolve `before`/`after` placements. + * @default 'ltr' + */ + dir?: 'ltr' | 'rtl'; + + /** + * Target document used for devtools integration. + * @default container.ownerDocument + */ + targetDocument?: Document; + + /** + * Internal escape hatch to disable Floating UI shift middleware entirely. + */ + unstable_disableShift?: boolean; + + /** + * Internal override for Floating UI flip fallback behavior. + * @default 'bestFit' + */ + unstable_flipFallbackStrategy?: PositioningFlipFallbackStrategy_unstable; +} + export type PositioningShorthandValue = | 'above' | 'above-start' diff --git a/packages/react-components/react-positioning/library/src/usePositioning.test.tsx b/packages/react-components/react-positioning/library/src/usePositioning.test.tsx index d92b4736def382..450e8eb0a3aeed 100644 --- a/packages/react-components/react-positioning/library/src/usePositioning.test.tsx +++ b/packages/react-components/react-positioning/library/src/usePositioning.test.tsx @@ -1,15 +1,16 @@ import { act, render } from '@testing-library/react'; import * as React from 'react'; +import { createPositionManager } from './createPositionManager'; import { usePositioning } from './usePositioning'; import { POSITIONING_END_EVENT } from './constants'; -import type { OnPositioningEndEvent, OnPositioningEndEventDetail, PositioningProps } from './types'; +import type { OnPositioningEndEvent, OnPositioningEndEventDetail, PositioningImperativeRef, PositioningProps } from './types'; // Mock createPositionManager to avoid @floating-ui/dom dependency in this test. // The mock dispatches the positioning end event asynchronously (via microtask), // matching the real implementation's debounce + computePosition promise chain. jest.mock('./createPositionManager', () => ({ - createPositionManager: jest.fn(({ container }: { container: HTMLElement }) => { - const dispatchEnd = () => { + createPositionManager: jest.fn(({ container }) => { + const dispatchEnd = jest.fn(() => { Promise.resolve().then(() => { container.dispatchEvent( new CustomEvent(POSITIONING_END_EVENT, { @@ -21,7 +22,7 @@ jest.mock('./createPositionManager', () => ({ }), ); }); - }; + }); dispatchEnd(); @@ -32,8 +33,20 @@ jest.mock('./createPositionManager', () => ({ }), })); -const TestComponent: React.FC<{ onPositioningEnd?: PositioningProps['onPositioningEnd'] }> = ({ onPositioningEnd }) => { - const { targetRef, containerRef } = usePositioning({ onPositioningEnd }); +const createPositionManagerMock = createPositionManager as jest.MockedFunction; +const getLastCreatedManager = () => { + const { results } = createPositionManagerMock.mock; + return results[results.length - 1]?.value; +}; + +function TestComponent({ + onPositioningEnd, + positioningRef, +}: { + onPositioningEnd?: PositioningProps['onPositioningEnd']; + positioningRef?: PositioningProps['positioningRef']; +}) { + const { targetRef, containerRef } = usePositioning({ onPositioningEnd, positioningRef }); return ( <> @@ -45,9 +58,13 @@ const TestComponent: React.FC<{ onPositioningEnd?: PositioningProps['onPositioni ); -}; +} describe('usePositioning', () => { + beforeEach(() => { + createPositionManagerMock.mockClear(); + }); + describe('onPositioningEnd', () => { it('calls onPositioningEnd with the positioning event', async () => { const onPositioningEnd = jest.fn(); @@ -79,4 +96,35 @@ describe('usePositioning', () => { }); }); }); + + it('still exposes updatePosition through positioningRef', async () => { + const positioningRef = React.createRef(); + render(); + + await act(async () => { + await new Promise(process.nextTick); + }); + + const manager = getLastCreatedManager(); + + act(() => { + positioningRef.current?.updatePosition(); + }); + + expect(manager?.updatePosition).toHaveBeenCalledTimes(2); + }); + + it('disposes the current manager on unmount', async () => { + const { unmount } = render(); + + await act(async () => { + await new Promise(process.nextTick); + }); + + const manager = getLastCreatedManager(); + + unmount(); + + expect(manager?.dispose).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/react-components/react-positioning/library/src/usePositioningOptions.ts b/packages/react-components/react-positioning/library/src/usePositioningOptions.ts index ef8719797caca0..4c81c0a0cde680 100644 --- a/packages/react-components/react-positioning/library/src/usePositioningOptions.ts +++ b/packages/react-components/react-positioning/library/src/usePositioningOptions.ts @@ -1,193 +1,94 @@ '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 { resolvePositioningOptions, type ResolvedPositioningOptions } from './resolvePositioningOptions'; +import type { PositioningOptions } from './types'; import { usePositioningConfiguration } from './PositioningConfigurationContext'; /** - * 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 positioningConfiguration = usePositioningConfiguration(); const { align, arrowPadding, autoSize, coverTarget, disableUpdateOnResize, + fallbackPositions, flipBoundary, + matchTargetSize, offset, overflowBoundary, + overflowBoundaryPadding, pinned, position, + // eslint-disable-next-line @typescript-eslint/no-deprecated + positionFixed, + shiftToCoverTarget, + strategy, // eslint-disable-next-line @typescript-eslint/naming-convention unstable_disableTether, - strategy, - overflowBoundaryPadding, - fallbackPositions, useTransform, - matchTargetSize, - shiftToCoverTarget, } = options; return React.useCallback( - (container: HTMLElement, arrow: HTMLElement | null) => { - return configFn({ - container, + (container: HTMLElement, arrow: HTMLElement | null) => + resolvePositioningOptions({ + align, arrow, - options: { - 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, - }, - }); - }, - [ - autoSize, - disableUpdateOnResize, - matchTargetSize, - offset, - strategy, - coverTarget, - flipBoundary, - overflowBoundary, - useTransform, - overflowBoundaryPadding, - pinned, - arrowPadding, - align, - fallbackPositions, - shiftToCoverTarget, - position, - unstable_disableTether, - configFn, - ], - ); -} - -/** - * @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 { + arrowPadding, autoSize, + container, + coverTarget, disableUpdateOnResize, + fallbackPositions, + flipBoundary, + isRtl, matchTargetSize, offset, - coverTarget, - flipBoundary, overflowBoundary, - useTransform, overflowBoundaryPadding, pinned, position, - arrowPadding, - strategy, - align, - fallbackPositions, + positionFixed, + positioningConfiguration, shiftToCoverTarget, + strategy, + targetDocument, // 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], + }), + [ + align, + arrowPadding, + autoSize, + coverTarget, + disableUpdateOnResize, + fallbackPositions, + flipBoundary, + isRtl, + matchTargetSize, + offset, + overflowBoundary, + overflowBoundaryPadding, + pinned, + position, + positionFixed, + positioningConfiguration, + shiftToCoverTarget, + strategy, + targetDocument, + unstable_disableTether, + useTransform, + ], ); } From 26c7a16d6644e40c104461238ee4217b9174a45f Mon Sep 17 00:00:00 2001 From: Sean Monahan Date: Fri, 7 Aug 2026 18:51:02 +0000 Subject: [PATCH 02/11] feat(headless): add lazy overlay fallback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...-4f664f9c-a7db-4b10-b809-79ba82caab92.json | 7 + .../library/.swcrc | 2 +- .../bundle-size/OverlayRuntime.fixture.js | 11 + .../OverlayRuntimeCommonJS.fixture.cjs | 6 + .../library/config/tests.js | 36 +- .../library/docs/overlay-runtime-fallback.md | 173 +++++++ .../library/docs/popover-spec.md | 25 +- .../library/package.json | 3 +- .../library/project.json | 19 + .../scripts/verify-overlay-runtime-bundle.cjs | 118 +++++ .../components/Combobox/renderCombobox.tsx | 10 +- .../src/components/Combobox/useCombobox.ts | 5 +- .../DialogSurface/renderDialogSurface.tsx | 36 +- .../Dialog/DialogSurface/useDialogSurface.ts | 56 ++- .../components/Dropdown/Listbox/useListbox.ts | 12 +- .../components/Dropdown/renderDropdown.tsx | 10 +- .../src/components/Dropdown/useDropdown.ts | 5 +- .../Dropdown/useListboxPopupState.ts | 40 +- .../src/components/Dropdown/useListboxSlot.ts | 14 +- .../Menu/MenuPopover/useMenuPopover.ts | 25 +- .../src/components/Menu/renderMenu.tsx | 23 +- .../library/src/components/Menu/useMenu.ts | 40 +- .../Popover/Popover.fallback.test.tsx | 75 +++ .../Popover/Popover.internal-types.ts | 19 + .../PopoverSurface/renderPopoverSurface.tsx | 4 +- .../PopoverSurface/usePopoverSurface.ts | 21 +- .../src/components/Popover/renderPopover.tsx | 21 +- .../src/components/Popover/usePopover.ts | 39 +- .../components/TagPicker/renderTagPicker.tsx | 43 +- .../Toast/Toaster/renderToaster.tsx | 5 +- .../components/Toast/Toaster/useToaster.tsx | 17 +- .../src/components/Tooltip/renderTooltip.tsx | 14 +- .../src/components/Tooltip/useTooltip.ts | 47 +- .../library/src/hooks/usePositioning/index.ts | 3 +- .../usePositioning/usePositioning.test.tsx | 144 +++++- .../hooks/usePositioning/usePositioning.ts | 124 ++++- .../src/hooks/usePositioning/utils/index.ts | 9 +- .../hooks/usePositioning/utils/placement.ts | 90 +++- .../utils/resolvePositioningShorthand.test.ts | 25 + .../utils/resolvePositioningShorthand.ts | 34 ++ .../src/overlayRuntime/OverlaySurfaceHost.tsx | 59 +++ .../components.fallback.test.tsx | 141 ++++++ .../src/overlayRuntime/fallbackRuntime.ts | 429 ++++++++++++++++++ .../library/src/overlayRuntime/index.ts | 17 + .../overlayRuntime/nativeCapabilities.test.ts | 78 ++++ .../src/overlayRuntime/nativeCapabilities.ts | 66 +++ .../overlayRuntime/overlayRuntime.test.tsx | 99 ++++ .../src/overlayRuntime/overlayRuntime.ts | 120 +++++ .../library/src/overlayRuntime/types.ts | 5 + .../src/shared/browserSupportNotice.ts | 9 +- .../src/executors/build/lib/swc.spec.ts | 72 +++ .../src/executors/build/lib/swc.ts | 6 +- .../src/executors/build/schema.d.ts | 4 + .../src/executors/build/schema.json | 5 + yarn.lock | 10 +- 55 files changed, 2426 insertions(+), 104 deletions(-) create mode 100644 change/@fluentui-react-headless-components-preview-4f664f9c-a7db-4b10-b809-79ba82caab92.json create mode 100644 packages/react-components/react-headless-components-preview/library/bundle-size/OverlayRuntime.fixture.js create mode 100644 packages/react-components/react-headless-components-preview/library/bundle-size/OverlayRuntimeCommonJS.fixture.cjs create mode 100644 packages/react-components/react-headless-components-preview/library/docs/overlay-runtime-fallback.md create mode 100644 packages/react-components/react-headless-components-preview/library/scripts/verify-overlay-runtime-bundle.cjs create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.fallback.test.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.internal-types.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/resolvePositioningShorthand.test.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/resolvePositioningShorthand.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/overlayRuntime/OverlaySurfaceHost.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/overlayRuntime/components.fallback.test.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/overlayRuntime/fallbackRuntime.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/overlayRuntime/index.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/overlayRuntime/nativeCapabilities.test.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/overlayRuntime/nativeCapabilities.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/overlayRuntime/overlayRuntime.test.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/overlayRuntime/overlayRuntime.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/overlayRuntime/types.ts create mode 100644 tools/workspace-plugin/src/executors/build/lib/swc.spec.ts diff --git a/change/@fluentui-react-headless-components-preview-4f664f9c-a7db-4b10-b809-79ba82caab92.json b/change/@fluentui-react-headless-components-preview-4f664f9c-a7db-4b10-b809-79ba82caab92.json new file mode 100644 index 00000000000000..d2420f8c5d6a62 --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-4f664f9c-a7db-4b10-b809-79ba82caab92.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: add a lazy Fluent positioning fallback for unsupported overlay browsers", + "packageName": "@fluentui/react-headless-components-preview", + "email": "email not defined", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-headless-components-preview/library/.swcrc b/packages/react-components/react-headless-components-preview/library/.swcrc index 6ae360015ce576..f81325b0dab6b0 100644 --- a/packages/react-components/react-headless-components-preview/library/.swcrc +++ b/packages/react-components/react-headless-components-preview/library/.swcrc @@ -14,7 +14,7 @@ "syntax": "typescript", "tsx": true, "decorators": false, - "dynamicImport": false + "dynamicImport": true }, "externalHelpers": true, "transform": { diff --git a/packages/react-components/react-headless-components-preview/library/bundle-size/OverlayRuntime.fixture.js b/packages/react-components/react-headless-components-preview/library/bundle-size/OverlayRuntime.fixture.js new file mode 100644 index 00000000000000..97b3dc5af487e8 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/bundle-size/OverlayRuntime.fixture.js @@ -0,0 +1,11 @@ +import * as Dialog from '@fluentui/react-headless-components-preview/dialog'; +import * as Menu from '@fluentui/react-headless-components-preview/menu'; +import * as Popover from '@fluentui/react-headless-components-preview/popover'; +import * as Toast from '@fluentui/react-headless-components-preview/toast'; +import * as Tooltip from '@fluentui/react-headless-components-preview/tooltip'; + +console.log({ Dialog, Menu, Popover, Toast, Tooltip }); + +export default { + name: 'react-headless-components-preview: overlay runtime', +}; diff --git a/packages/react-components/react-headless-components-preview/library/bundle-size/OverlayRuntimeCommonJS.fixture.cjs b/packages/react-components/react-headless-components-preview/library/bundle-size/OverlayRuntimeCommonJS.fixture.cjs new file mode 100644 index 00000000000000..da7a399f7698f5 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/bundle-size/OverlayRuntimeCommonJS.fixture.cjs @@ -0,0 +1,6 @@ +const Popover = require('@fluentui/react-headless-components-preview/popover'); + +// The CommonJS probe verifies that Headless's own dynamic import remains an +// asynchronous boundary after SWC compilation. The ESM fixture covers the full +// overlay family used by browser bundlers. +console.log({ Popover }); diff --git a/packages/react-components/react-headless-components-preview/library/config/tests.js b/packages/react-components/react-headless-components-preview/library/config/tests.js index 3507289757d2ef..0950330a8b2cc6 100644 --- a/packages/react-components/react-headless-components-preview/library/config/tests.js +++ b/packages/react-components/react-headless-components-preview/library/config/tests.js @@ -1,7 +1,41 @@ /** Jest test setup file. */ require('@testing-library/jest-dom'); -require('@oddbird/popover-polyfill'); + +// Unit tests exercise native behavior by default. Focused fallback tests override +// this value before their first overlay runtime lookup. +window.__FUI_HEADLESS_OVERLAY_RUNTIME_MODE__ = 'native'; + +const openPopovers = new WeakSet(); +const dispatchToggle = (element, newState) => { + const event = new Event('toggle'); + Object.defineProperty(event, 'newState', { value: newState }); + element.dispatchEvent(event); +}; + +if (!HTMLElement.prototype.showPopover) { + HTMLElement.prototype.showPopover = function showPopover() { + openPopovers.add(this); + dispatchToggle(this, 'open'); + }; +} + +if (!HTMLElement.prototype.hidePopover) { + HTMLElement.prototype.hidePopover = function hidePopover() { + if (openPopovers.delete(this)) { + dispatchToggle(this, 'closed'); + } + }; +} + +const nativeMatches = Element.prototype.matches; +Element.prototype.matches = function matches(selector) { + if (selector === ':popover-open') { + return openPopovers.has(this); + } + + return nativeMatches.call(this, selector); +}; global.ResizeObserver = class ResizeObserver { observe() { diff --git a/packages/react-components/react-headless-components-preview/library/docs/overlay-runtime-fallback.md b/packages/react-components/react-headless-components-preview/library/docs/overlay-runtime-fallback.md new file mode 100644 index 00000000000000..a0c4d78ed1e583 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/docs/overlay-runtime-fallback.md @@ -0,0 +1,173 @@ +# Overlay runtime fallback contract + +## Status + +This document defines the implementation-neutral behavior for Headless overlay +components. Native browser behavior is the baseline. A fallback implementation +may use Fluent UI's Portal, focus-management, and positioning utilities, but it +must not expose different behavior to consumers. + +The runtime decision is binary for a given document: + +- **Native** when the complete HTML Popover and CSS Anchor Positioning contract + used by Headless is available. +- **Fallback** when any required capability is missing. + +Components do not select a runtime independently. A document must not mix +native and fallback overlay stacks. + +## Component inventory + +| Category | Components | +| --- | --- | +| Anchored popovers | Popover, TeachingPopover, InfoLabel, AvatarGroupPopover | +| Anchored selection surfaces | Menu, Dropdown, Combobox, TagPicker | +| Anchored hints | Tooltip | +| Non-anchored overlays | Dialog, Drawer, Toaster | + +TeachingPopover, InfoLabel, and AvatarGroupPopover inherit the Popover +contract. Drawer inherits the Dialog contract. TagPicker participates in the +positioning runtime even when its rendered surface does not directly call the +Popover API. + +## Native capability gate + +Native mode requires all capabilities below in the target document's realm: + +- `HTMLElement.prototype.showPopover` +- `HTMLElement.prototype.hidePopover` +- the `:popover-open` selector +- the `hint` popover value used by Tooltip +- `HTMLDialogElement.prototype.showModal` +- `anchor-name` +- `position-anchor` +- `position-area` +- `position-try-fallbacks`, including flip tactics +- `anchor-size()` + +Partial support selects fallback mode. Detection must use the target document +and its default view rather than the module's global realm. + +## Shared behavioral contract + +| Contract | Native baseline | Fallback requirement | +| --- | --- | --- | +| Layering | Browser top layer | Render through the Fluent Portal associated with the target document | +| Open state | `showPopover()`, `hidePopover()`, or `showModal()` | Preserve the same controlled and uncontrolled React state | +| Light dismiss | `popover="auto"` | Match outside-click, Escape, iframe-focus, peer, and nested-overlay dismissal | +| Manual popovers | React controls `popover="manual"` | Do not add light-dismiss behavior that native mode does not have | +| Modal focus | Native dialog focus, inertness, and restoration | Match with Fluent focus-management utilities | +| Initial focus | Native dialog/popover focusing steps and `autofocus` | Honor the same author-selected target and timing | +| Focus restoration | Browser restoration where specified | Restore to the same trigger or previously focused element | +| Nested overlays | Native auto-popover and dialog stack | Preserve parent/child dismissal and modal suspension semantics | +| Hint behavior | Native `popover="hint"` | Preserve one-visible-tooltip and Escape behavior | +| Events | Native `toggle` mirrored into React | Emit the same `onOpenChange` or visibility callback once | +| ARIA | Existing roles, ids, relationships, and state attributes | Preserve the same observable DOM contract | + +## Positioning contract + +The public Headless positioning surface is the intersection supported by the +native implementation. The fallback must not enable additional +`@fluentui/react-positioning` behavior implicitly. + +| Option or result | Native behavior | Fallback requirement | +| --- | --- | --- | +| `position` / `align` | Logical `position-area` mapping | Equivalent logical placement | +| Default strategy | `fixed` | Explicitly use `fixed` | +| Default collision chain | `flip-block`, `flip-inline`, then both | Generate the equivalent ordered placements | +| `fallbackPositions` | Try supplied position areas in order | Preserve supplied order | +| No fitting fallback | Browser retains its specified fallback result | Do not substitute Floating UI `bestFit` behavior | +| Shift | No separate shifting contract | Disable fallback-only shifting | +| `pinned` | No fallback attempts | Disable flip and shift | +| Numeric offset | Logical main-axis gap | Equivalent Floating UI offset | +| Object offset | Logical main- and cross-axis values | Equivalent normalized offset | +| Function offset | Resolves to zero because native layout rects are unavailable | Also resolve to zero | +| `matchTargetSize: "width"` | `anchor-size(width)` | Match target width | +| `coverTarget` | `position-area: center` plus self-alignment | Equivalent cover-target placement | +| Custom target | Element or virtual target | Equivalent target support | +| Imperative ref | `setTarget()` and `updatePosition()` | Delegate to the active backend without ref replacement | +| Arrow | CSS anchor placement | Floating UI arrow placement against the same arrow element | +| Resolved placement | Logical Headless `data-placement` | Convert physical Floating UI placement to the same logical value | +| RTL | Logical CSS behavior | Produce the same `data-placement` and visual side | + +For example, the default fallback sequence for `above-start` is: + +1. `below-start` (`flip-block`) +2. `above-end` (`flip-inline`) +3. `below-end` (`flip-block flip-inline`) + +The same logical transforms apply to horizontal placements. For +`before-top`, the sequence is `before-bottom`, `after-top`, then +`after-bottom`. + +## Runtime loading contract + +- The first overlay mount in an unsupported document starts one shared dynamic + import. +- Concurrent mounts reuse the same promise and runtime instance. +- Open state may change while loading. The requested surface appears when the + runtime is ready without another interaction. +- A fallback surface must not briefly render at an unpositioned static + location. +- A rejected import is an explicit runtime error. Components must not silently + report an open state while rendering no usable surface. +- Server rendering must not access browser globals or choose a browser runtime. +- Runtime state and capability results are cached per document so iframes and + alternate Fluent target documents remain isolated. + +## Bundle contract + +Native-capable consumers must not download fallback implementation code. + +- Native entry chunks must exclude `@fluentui/react-positioning`, + `@floating-ui/*`, and fallback-only Portal/focus modules. +- All Headless overlay entry points must reference one shared async fallback + chunk. +- Both ESM and CommonJS package outputs must preserve an asynchronous load + boundary. +- Type-only references to `@fluentui/react-positioning` are allowed. +- Runtime helpers such as positioning shorthand normalization must live in a + native-safe module rather than statically importing the fallback package. + +## Component-specific acceptance + +### Popover and compositions + +Click, keyboard, hover, context-menu, controlled, triggerless, modal, and +nested cases must preserve their current callbacks, focus behavior, and +logical placement. + +### Menu + +Fallback mode must preserve submenu chains, context targets, hover delay, +safe-zone behavior, item focus, and dismissal of the correct menu depth. + +### Tooltip + +Fallback mode must preserve show/hide delays, label and description +relationships, one-visible-tooltip coordination, Escape, hidden-reference +behavior, and arrow placement. + +### Dropdown, Combobox, and TagPicker + +Fallback mode must preserve active-descendant behavior, trigger focus, +outside-click dismissal, inline-popup behavior, target-width matching, and +logical placement. + +### Dialog and Drawer + +Modal, alert, and non-modal variants must preserve their distinct dismissal, +backdrop, scroll-lock, focus-trap, nested-dialog, and restoration behavior. +Non-modal fallback must not accidentally gain auto-popover light dismiss. + +### Toaster + +Fallback mode must portal populated position containers without changing toast +ordering, keyboard behavior, announcements, focus restoration, or position +styling contracts. + +## Internal override + +Tests and emergency rollout controls may force `native` or `fallback` before a +document's runtime is selected. The override is not a component prop or a +supported public API, and tests must reset it between cases. diff --git a/packages/react-components/react-headless-components-preview/library/docs/popover-spec.md b/packages/react-components/react-headless-components-preview/library/docs/popover-spec.md index bdda419957b68e..41675f105cbd84 100644 --- a/packages/react-components/react-headless-components-preview/library/docs/popover-spec.md +++ b/packages/react-components/react-headless-components-preview/library/docs/popover-spec.md @@ -2,9 +2,9 @@ ## Overview -Popover is an anchored overlay surface that displays transient content (actions, details, confirmations, rich tooltips) next to a trigger element. It composes a trigger (optional if opened programmatically), a surface (the floating content), and an optional arrow. The surface elevates into the browser's **top layer** via the native HTML Popover API. Placement is computed via the native **CSS Anchor Positioning API** — no JS layout loop. +Popover is an anchored overlay surface that displays transient content (actions, details, confirmations, rich tooltips) next to a trigger element. It composes a trigger (optional if opened programmatically), a surface (the floating content), and an optional arrow. Browsers with the complete overlay feature set use the HTML Popover API and CSS Anchor Positioning. Other browsers load a shared Portal and Fluent positioning fallback when the first overlay mounts. -Popover lets the browser manage dismissal: the surface is rendered with `popover="auto"`, so Escape, click-outside, and popover-stack peer-dismissal happen at HTML Popover spec timing and are mirrored back into React via the surface's `toggle` event. Open paths (click, hover, context-menu, controlled `open`) flow through React; close paths defer to the browser. Focus trapping is deferred to a later iteration — the surface is currently a non-modal `role="group"`. +Open paths (click, hover, context-menu, controlled `open`) always flow through React. In native mode, `popover="auto"` owns Escape, click-outside, and peer dismissal and the `toggle` event mirrors those decisions back into state. Fallback mode supplies the equivalent Portal, dismissal-stack, and focus behavior. `trapFocus` selects modal dialog semantics in either runtime. ## Composition @@ -59,7 +59,7 @@ Popover is a compound component. `PopoverTrigger` is optional — a surface with | **Hover-held** | `openOnHover` and pointer inside trigger or surface | Popover stays open while pointer is inside either element; closes `mouseLeaveDelay` ms after it leaves both. | Same as Open. | | **Context-pinned** | `openOnContext` + right-click | `onOpenChange(e, { type: 'contextmenu', open: true })` with the mouse event; `contextTarget` state stores `{ x, y }`. Click and keyboard activation on the trigger do nothing. | Same as Open. | | **Dismissing** | Browser-driven: Escape, click-outside, popover-stack peer dismissal. Plus React-driven hover-leave (`openOnHover`) and programmatic close. | `toggle` event on the surface mirrors the browser's decision into React; `onOpenChange(e, { open: false, type })` fires with the originating event. | `aria-expanded` returns to `"false"` on trigger. | -| **Nested** | Popover rendered inside another Popover's surface | Nested popovers participate in the browser-managed `popover="auto"` stack. Escape, click-outside, and peer-dismissal use native HTML Popover semantics, and each surface mirrors its own `toggle` event back into React — no React-side Escape filtering. A nested `trapFocus` popover stacks via `showModal()` and suspends the outer trap until it closes (browser top-layer rules). | Each surface keeps its own role per its `trapFocus` setting. | +| **Nested** | Popover rendered inside another Popover's surface | Nested popovers participate in one implementation-neutral stack. Native mode uses the browser popover/dialog stack; fallback mode mirrors the same Escape, click-outside, peer-dismissal, and modal-suspension behavior. | Each surface keeps its own role per its `trapFocus` setting. | ## Keyboard Navigation @@ -132,7 +132,7 @@ This matches the behavioural guarantees consumers would get from `popovertarget` ### Surface element -`PopoverSurface` always renders as a native **``**. A single element supports both show modes the component switches between at open time: +`PopoverSurface` always renders a **``**. In native mode it receives `popover="auto"` for the non-modal path. In fallback mode it receives `open` and is rendered through a Fluent Portal: - `surface.showPopover()` — non-modal popover (default, `trapFocus={false}`). - `surface.showModal()` — modal dialog (`trapFocus={true}`). @@ -174,7 +174,7 @@ The headless Popover does **not** add `aria-live` to the surface. Consumers rend ## Positioning -Placement is handled entirely by the `usePositioning` hook, which writes native CSS anchor-positioning properties onto the surface element. No JS layout loop. +Placement is handled entirely by the `usePositioning` hook. Native mode writes CSS anchor-positioning properties. Fallback mode creates the equivalent `@fluentui/react-positioning` manager after the shared fallback chunk loads. ### Options (all optional) @@ -189,7 +189,7 @@ Placement is handled entirely by the `usePositioning` hook, which writes native | `matchTargetSize` | `'width'` | — | Sets the surface's `width` to `anchor-size(width)`. | | `strategy` | `'fixed' \| 'absolute'` | `'absolute'` | CSS `position` property value on the surface. Matches v9's default. Use `'fixed'` when the surface needs to escape transformed / `contain: layout` ancestors for anchoring purposes. | | `target` | `HTMLElement \| RefObject` | — | Custom anchor element. When set, `anchor-name` is written on this element instead of the trigger. | -| `positioningRef` | `Ref` | — | `{ setTarget(el): void; updatePosition(): void }`. `updatePosition` is a no-op — native positioning self-updates. | +| `positioningRef` | `Ref` | — | `{ setTarget(el): void; updatePosition(): void }`. `updatePosition` refreshes resolved placement in the active backend. | ### Rendering @@ -310,15 +310,16 @@ Positioning uses CSS _logical_ properties throughout (`block-start`, `block-end` - `align: 'start'` anchors at the writing-mode start — top in horizontal-tb, left in vertical-rl. - Physical v9 aliases (`top` / `bottom` / `left` / `right`) are normalized at the shorthand boundary and become logical internally. -## Native API surface +## Overlay runtime selection -The package relies on three native browser APIs: +Native mode requires the complete set used by Headless: -- **CSS Anchor Positioning** (Chromium 125+) — `anchor-name`, `position-anchor`, `position-area`, `anchor-size()`, `position-try-fallbacks`. -- **HTML Popover API** (Chromium 114+) — `popover="auto"` + `showPopover()` + the `toggle` event for top-layer elevation, light dismiss, and state mirroring. Feature-detected (`typeof el.showPopover === 'function'`); SSR-safe. +- **CSS Anchor Positioning** — `anchor-name`, `position-anchor`, `position-area`, `anchor-size()`, and `position-try-fallbacks`. +- **HTML Popover API** — `popover="auto"` / `popover="hint"`, `showPopover()`, `hidePopover()`, `:popover-open`, and `toggle`. +- **HTML Dialog API** — `showModal()`. - **ResizeObserver** — used sparingly by `usePlacementObserver` (for live `data-placement`) -Firefox and Safari are implementing CSS Anchor Positioning; most features work but flip behaviour is still WIP. +Missing any required Popover or anchor-positioning capability selects fallback mode for every overlay in that document. The fallback chunk is shared by Popover, Menu, Tooltip, selection popups, Dialog/Drawer, and Toaster. See [Overlay runtime fallback contract](./overlay-runtime-fallback.md). ## Notes @@ -326,4 +327,4 @@ Firefox and Safari are implementing CSS Anchor Positioning; most features work b - **Nested popovers**: nesting is JSX-nesting. The browser's popover-stack treats the inner trigger's DOM descendancy of the outer surface as the ancestor signal — Escape closes only the topmost popover, click-outside dismisses the chain. - **Hover-to-open**: `openOnHover` opens on `mouseenter` of the trigger and _stays_ open while the pointer is over the surface. Closing waits `mouseLeaveDelay` ms; this is the one close path that is React-driven (it unmounts the surface, which implicitly closes the native popover). - **Context popovers**: when `openOnContext={true}`, the mouse event's `clientX` / `clientY` are stored as `contextTarget` state — available to consumers via the popover context if they want to anchor the surface at the cursor position instead of on the trigger. -- **Positioning is CSS, not JS**: because placement computation is pushed to the browser, there's no JS layout loop and `positioning.updatePosition()` is a no-op. Consumers that need imperative retargeting use `positioning.setTarget(el)`. +- **Positioning backend is private**: native mode uses CSS with no JS layout loop. Fallback mode uses Fluent positioning, normalized to the native placement, offset, fallback-order, and `data-placement` contract. 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 2a32c5be1b2178..2f6374a2c9abc5 100644 --- a/packages/react-components/react-headless-components-preview/library/package.json +++ b/packages/react-components/react-headless-components-preview/library/package.json @@ -45,6 +45,7 @@ "@fluentui/react-overflow": "^9.9.1", "@fluentui/react-persona": "^9.7.6", "@fluentui/react-popover": "^9.14.5", + "@fluentui/react-portal": "^9.8.14", "@fluentui/react-positioning": "^9.23.0", "@fluentui/react-progress": "^9.5.3", "@fluentui/react-provider": "^9.22.19", @@ -400,6 +401,6 @@ "devDependencies": { "@fluentui/scripts-cypress": "*", "@fluentui/verify-bundle-isolation": "*", - "@oddbird/popover-polyfill": "^0.6.1" + "webpack": "5.108.4" } } diff --git a/packages/react-components/react-headless-components-preview/library/project.json b/packages/react-components/react-headless-components-preview/library/project.json index 7b99dac170d309..5df00c60281757 100644 --- a/packages/react-components/react-headless-components-preview/library/project.json +++ b/packages/react-components/react-headless-components-preview/library/project.json @@ -6,6 +6,11 @@ "tags": ["vNext", "platform:web", "react-headless"], "implicitDependencies": [], "targets": { + "build": { + "options": { + "preserveDynamicImport": true + } + }, "generate-api": { "options": { "exportSubpaths": true @@ -24,6 +29,20 @@ "technologies": ["webpack"], "description": "Assert entry points do not bundle tabster, Griffel or react-icons" } + }, + "verify-overlay-runtime-bundle": { + "cache": true, + "dependsOn": ["build", "^build"], + "command": "node scripts/verify-overlay-runtime-bundle.cjs OverlayRuntime.fixture.js && node scripts/verify-overlay-runtime-bundle.cjs OverlayRuntimeCommonJS.fixture.cjs", + "options": { + "cwd": "{projectRoot}" + }, + "inputs": ["default", "^default", { "externalDependencies": ["webpack"] }], + "outputs": ["{projectRoot}/dist/overlay-runtime-bundle"], + "metadata": { + "technologies": ["webpack"], + "description": "Assert the overlay fallback remains outside initial bundles" + } } } } diff --git a/packages/react-components/react-headless-components-preview/library/scripts/verify-overlay-runtime-bundle.cjs b/packages/react-components/react-headless-components-preview/library/scripts/verify-overlay-runtime-bundle.cjs new file mode 100644 index 00000000000000..20ec6c2adb72b1 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/scripts/verify-overlay-runtime-bundle.cjs @@ -0,0 +1,118 @@ +const path = require('node:path'); + +const webpack = require('webpack'); + +const packageRoot = path.resolve(__dirname, '..'); +const workspaceRoot = path.resolve(packageRoot, '../../../..'); +const fixture = process.argv[2] ?? 'OverlayRuntime.fixture.js'; +const outputPath = path.join( + packageRoot, + 'dist', + 'overlay-runtime-bundle', + path.parse(fixture).name, +); +const entry = path.join(packageRoot, 'bundle-size', fixture); + +const isFallbackRuntime = resource => + resource.includes(`${path.sep}overlayRuntime${path.sep}fallbackRuntime.`); + +const isFallbackDependency = resource => + resource.includes(`${path.sep}react-positioning${path.sep}`) || + resource.includes(`${path.sep}@fluentui${path.sep}react-positioning${path.sep}`) || + resource.includes(`${path.sep}@floating-ui${path.sep}`); + +class VerifyOverlayRuntimeChunksPlugin { + apply(compiler) { + compiler.hooks.afterEmit.tap('VerifyOverlayRuntimeChunksPlugin', compilation => { + const fallbackChunks = new Set(); + const initialViolations = new Set(); + + for (const module of compilation.modules) { + const resource = module.resource ?? module.nameForCondition?.(); + if (!resource) { + continue; + } + + for (const chunk of compilation.chunkGraph.getModuleChunks(module)) { + if (isFallbackRuntime(resource)) { + fallbackChunks.add(chunk); + } + + if (isFallbackDependency(resource) && chunk.canBeInitial()) { + initialViolations.add(resource); + } + } + } + + if (fallbackChunks.size !== 1) { + compilation.errors.push( + new Error(`Expected one async overlay fallback boundary, found ${fallbackChunks.size}.`), + ); + } else if ([...fallbackChunks][0].canBeInitial()) { + compilation.errors.push(new Error('Overlay fallback runtime was retained in an initial chunk.')); + } + + if (initialViolations.size > 0) { + compilation.errors.push( + new Error( + [ + 'Fallback positioning code was retained in an initial chunk:', + ...[...initialViolations].sort().map(resource => ` ${resource}`), + ].join('\n'), + ), + ); + } + }); + } +} + +const compiler = webpack({ + name: 'overlay-runtime-bundle', + target: 'web', + mode: 'production', + context: workspaceRoot, + entry, + externals: { + react: 'react', + 'react-dom': 'react-dom', + 'react/jsx-runtime': 'react/jsx-runtime', + 'react/compiler-runtime': 'react/compiler-runtime', + }, + output: { + path: outputPath, + filename: 'index.js', + chunkFilename: '[name].js', + }, + performance: { hints: false }, + optimization: { + concatenateModules: false, + minimize: false, + splitChunks: false, + }, + plugins: [new VerifyOverlayRuntimeChunksPlugin()], +}); + +compiler.run((error, stats) => { + compiler.close(() => { + if (error) { + console.error(error); + process.exitCode = 1; + return; + } + + if (!stats) { + console.error('webpack finished without producing stats'); + process.exitCode = 1; + return; + } + + if (stats.hasErrors()) { + const errors = stats.toJson({ all: false, errors: true }).errors ?? []; + console.error(errors.map(item => item.message).join('\n')); + process.exitCode = 1; + return; + } + + console.log(`${fixture}: overlay fallback runtime is isolated in one asynchronous boundary.`); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Combobox/renderCombobox.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Combobox/renderCombobox.tsx index 8bc8e54a035340..3634c66100480d 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Combobox/renderCombobox.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Combobox/renderCombobox.tsx @@ -6,9 +6,12 @@ import type { JSXElement } from '@fluentui/react-utilities'; import { ActiveDescendantContextProvider } from '@fluentui/react-aria'; import { ListboxProvider } from '@fluentui/react-combobox'; import type { ComboboxContextValues, ComboboxSlots, ComboboxState } from './Combobox.types'; +import { OverlaySurfaceHost } from '../../overlayRuntime'; +import type { WithFallbackBehavior } from '../../overlayRuntime/types'; export const renderCombobox = (state: ComboboxState, contextValues: ComboboxContextValues): JSXElement => { assertSlots(state); + const { fallbackBehavior } = state as WithFallbackBehavior; return ( @@ -17,7 +20,12 @@ export const renderCombobox = (state: ComboboxState, contextValues: ComboboxCont {state.clearIcon && } {state.expandIcon && } - {state.open && state.listbox && } + {state.open && state.listbox ? ( + + + + ) : null} + {fallbackBehavior} diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Combobox/useCombobox.ts b/packages/react-components/react-headless-components-preview/library/src/components/Combobox/useCombobox.ts index 88cfb273951b94..f7d3950249d26e 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Combobox/useCombobox.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Combobox/useCombobox.ts @@ -7,6 +7,7 @@ import { useInputTriggerSlot } from '@fluentui/react-combobox'; import { Listbox } from '../Dropdown/Listbox'; import { stringifyDataAttribute } from '../../utils'; import { useListboxPopupState } from '../Dropdown/useListboxPopupState'; +import type { WithFallbackBehavior } from '../../overlayRuntime/types'; export const useCombobox = (props: ComboboxProps, ref: React.Ref): ComboboxState => { const { freeform } = props; @@ -20,6 +21,7 @@ export const useCombobox = (props: ComboboxProps, ref: React.Ref(props, { primarySlotTagName: 'input', fieldControlOptions: { supportsLabelFor: true, supportsRequired: true }, @@ -45,7 +47,7 @@ export const useCombobox = (props: ComboboxProps, ref: React.Ref 0 && !disabled && clearable && !multiselect; - const state: ComboboxState = { + const state: WithFallbackBehavior = { components: { root: 'div', input: 'input', expandIcon: 'span', clearIcon: 'span', listbox: Listbox }, root: rootSlot, input: { @@ -66,6 +68,7 @@ export const useCombobox = (props: ComboboxProps, ref: React.Ref; +}; /** * Render the final JSX of DialogSurface. @@ -22,12 +29,39 @@ export const renderDialogSurface = (state: DialogSurfaceState): JSXElement | nul } assertSlots(state); + const { fallbackBehavior, onFallbackBackdropClick } = + state as unknown as DialogSurfaceStateInternal; const content = ( ); + const fallbackContent = + state.modalType === 'non-modal' ? ( + content + ) : ( + + ); - return content; + return ( + <> + + {content} + + {fallbackBehavior} + + ); }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/DialogSurface/useDialogSurface.ts b/packages/react-components/react-headless-components-preview/library/src/components/Dialog/DialogSurface/useDialogSurface.ts index 54459461f3304b..c4a7024d7c840e 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/DialogSurface/useDialogSurface.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Dialog/DialogSurface/useDialogSurface.ts @@ -11,9 +11,15 @@ import { } from '@fluentui/react-utilities'; import { useDialogContext } from '../dialogContext'; import { stringifyDataAttribute } from '../../../utils'; +import { useOverlayRuntime } from '../../../overlayRuntime'; import { lockDocumentScroll, unlockDocumentScroll } from '../utils/scroll'; import type { DialogSurfaceProps, DialogSurfaceState } from './DialogSurface.types'; +type DialogSurfaceStateInternal = DialogSurfaceState & { + fallbackBehavior?: React.ReactElement; + onFallbackBackdropClick: React.MouseEventHandler; +}; + const SUPPORTS_POPOVER_OPEN_SELECTOR = typeof CSS !== 'undefined' && typeof CSS.supports === 'function' && CSS.supports('selector(:popover-open)'); @@ -32,6 +38,8 @@ type ToggleEvent = Event & { newState?: 'open' | 'closed' }; export const useDialogSurface = (props: DialogSurfaceProps, ref: React.Ref): DialogSurfaceState => { const { open, modalType, unmountOnClose, requestOpenChange, dialogTitleId } = useDialogContext(); const { targetDocument } = useFluent(); + const overlayRuntime = useOverlayRuntime(targetDocument); + const useNativeRuntime = overlayRuntime.mode === 'ssr' || overlayRuntime.mode === 'native'; const dialogRef = React.useRef(null); const previouslyFocusedElement = React.useRef(null); @@ -61,6 +69,13 @@ export const useDialogSurface = (props: DialogSurfaceProps, ref: React.Ref) => { props.onKeyDown?.(event); @@ -178,13 +193,46 @@ export const useDialogSurface = (props: DialogSurfaceProps, ref: React.Ref) => { + if ( + modalType === 'modal' && + event.target === event.currentTarget && + !event.isDefaultPrevented() + ) { + requestOpenChange({ + type: 'backdropClick', + open: false, + event: event as unknown as React.MouseEvent, + }); + } + }, + ); + const fallbackBehavior = + overlayRuntime.mode === 'fallback-ready' + ? React.createElement(overlayRuntime.runtime.FallbackDialogBehavior, { + contentRef: dialogRef as React.RefObject, + lockScroll: modalType !== 'non-modal', + modal: modalType !== 'non-modal', + onDismiss: event => + requestOpenChange({ + type: 'escapeKeyDown', + open: false, + event: event as unknown as React.KeyboardEvent, + }), + open, + targetDocument, + }) + : undefined; - const state: DialogSurfaceState = { + const state: DialogSurfaceStateInternal = { components: { root: 'dialog' }, open, unmountOnClose, modalType, shouldRender, + fallbackBehavior, + onFallbackBackdropClick: handleFallbackBackdropClick, root: slot.always( getIntrinsicElementProps('dialog', { role: modalType === 'alert' ? 'alertdialog' : undefined, @@ -193,9 +241,11 @@ export const useDialogSurface = (props: DialogSurfaceProps, ref: React.Ref): ListboxState => { const state = useListbox_unstable(props, ref); + const { targetDocument } = useFluent(); + const overlayRuntime = useOverlayRuntime(targetDocument); + const useNativeRuntime = overlayRuntime.mode === 'ssr' || overlayRuntime.mode === 'native'; // eslint-disable-next-line react-hooks/immutability - state.root.popover = 'auto'; + state.root.popover = useNativeRuntime ? 'auto' : undefined; + ( + state.root as ListboxState['root'] & { + 'data-overlay-runtime'?: 'native' | 'fallback'; + } + )['data-overlay-runtime'] = useNativeRuntime ? 'native' : 'fallback'; return state; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Dropdown/renderDropdown.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Dropdown/renderDropdown.tsx index 0fff5d895a60b9..da9efda4480d46 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Dropdown/renderDropdown.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Dropdown/renderDropdown.tsx @@ -6,12 +6,15 @@ import type { JSXElement } from '@fluentui/react-utilities'; import { ActiveDescendantContextProvider } from '@fluentui/react-aria'; import type { DropdownContextValues, DropdownState, DropdownSlots } from './Dropdown.types'; import { ListboxProvider } from '@fluentui/react-combobox'; +import { OverlaySurfaceHost } from '../../overlayRuntime'; +import type { WithFallbackBehavior } from '../../overlayRuntime/types'; /** * Render the final JSX of Dropdown */ export const renderDropdown = (state: DropdownState, contextValues: DropdownContextValues): JSXElement => { assertSlots(state); + const { fallbackBehavior } = state as WithFallbackBehavior; return ( @@ -22,7 +25,12 @@ export const renderDropdown = (state: DropdownState, contextValues: DropdownCont {state.expandIcon && } {state.clearButton && } - {state.open && state.listbox && } + {state.open && state.listbox ? ( + + + + ) : null} + {fallbackBehavior} diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Dropdown/useDropdown.ts b/packages/react-components/react-headless-components-preview/library/src/components/Dropdown/useDropdown.ts index 619ef256534ef9..b382ec5d73e755 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Dropdown/useDropdown.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Dropdown/useDropdown.ts @@ -8,6 +8,7 @@ import { useButtonTriggerSlot } from '@fluentui/react-combobox'; import { Listbox } from './Listbox'; import { stringifyDataAttribute } from '../../utils'; import { useListboxPopupState } from './useListboxPopupState'; +import type { WithFallbackBehavior } from '../../overlayRuntime/types'; /** * Create the state required to render Dropdown. @@ -28,6 +29,7 @@ export const useDropdown = (props: DropdownProps, ref: React.Ref(props, { primarySlotTagName: 'button', fieldControlOptions: { supportsLabelFor: true }, @@ -52,7 +54,7 @@ export const useDropdown = (props: DropdownProps, ref: React.Ref 0 && !disabled && clearable && !multiselect; - const state: DropdownState = { + const state: WithFallbackBehavior = { components: { root: 'div', button: 'button', clearButton: 'button', expandIcon: 'span', listbox: Listbox }, root: rootSlot, button: { @@ -80,6 +82,7 @@ export const useDropdown = (props: DropdownProps, ref: React.Ref, ): UseListboxPopupStateReturn { + const { targetDocument } = useFluent(); + const overlayRuntime = useOverlayRuntime(targetDocument); const { primarySlotTagName, fieldControlOptions, baseStateExtras, rootDefaultProps } = options; const positioningOptions = resolvePositioningShorthand(initialProps.positioning); @@ -117,14 +121,19 @@ export function useListboxPopupState(null); - - const listbox = useListboxSlot(props.listbox, useMergedRefs(comboboxPopupRef, activeDescendantListboxRef), { - state: internalState, - triggerRef, - defaultProps: { - children: props.children, + const popupRef = React.useRef(null); + + const listbox = useListboxSlot( + props.listbox, + useMergedRefs(comboboxPopupRef, activeDescendantListboxRef, popupRef), + { + state: internalState, + triggerRef, + defaultProps: { + children: props.children, + }, }, - }); + ); const rootSlot = slot.always(props.root ?? undefined, { defaultProps: { @@ -135,6 +144,20 @@ export function useListboxPopupState + internalState.setOpen( + event as unknown as Parameters[0], + false, + ), + open, + targetDocument, + triggerRef: triggerRef as React.RefObject, + }) + : undefined; if (process.env.NODE_ENV !== 'production') { // eslint-disable-next-line react-hooks/rules-of-hooks -- "process.env" does not change in runtime @@ -159,5 +182,6 @@ export function useListboxPopupState; @@ -42,6 +44,8 @@ export function useListboxSlot( triggerRef, defaultProps, } = options; + const { targetDocument } = useFluent(); + const overlayRuntime = useOverlayRuntime(targetDocument); const listboxId = useId( 'fluent-listbox', @@ -110,6 +114,10 @@ export function useListboxSlot( }); useIsomorphicLayoutEffect(() => { + if (overlayRuntime.mode !== 'native') { + return; + } + const el = listboxRef.current; if (!el) { return; @@ -128,8 +136,8 @@ export function useListboxSlot( // eslint-disable-next-line no-console console.warn( [ - 'Popover API is not supported in this browser, and the listbox will not work correctly.', - 'Please include a popover polyfill for better browser support.', + 'The native Popover API failed after overlay capability detection.', + 'The listbox could not synchronize its native open state.', ].join(' '), { error }, ); @@ -139,7 +147,7 @@ export function useListboxSlot( return () => { el.removeEventListener('toggle', onListboxToggle); }; - }, [listboxRef, open, onListboxToggle]); + }, [listboxRef, onListboxToggle, open, overlayRuntime.mode]); return listboxSlot; } diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuPopover/useMenuPopover.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuPopover/useMenuPopover.ts index df3b8e0238292d..fcbea7bb214dd3 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuPopover/useMenuPopover.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuPopover/useMenuPopover.ts @@ -2,8 +2,10 @@ import * as React from 'react'; import { useMenuPopoverBase_unstable } from '@fluentui/react-menu'; +import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; import { useMenuContext } from '../menuContext'; import type { MenuPopoverProps, MenuPopoverState } from '@fluentui/react-menu'; +import { useOverlayRuntime } from '../../../overlayRuntime'; const SUPPORTS_POPOVER_OPEN_SELECTOR = typeof CSS !== 'undefined' && typeof CSS.supports === 'function' && CSS.supports('selector(:popover-open)'); @@ -12,17 +14,28 @@ type ToggleEvent = Event & { newState?: 'open' | 'closed' }; export const useMenuPopover = (props: MenuPopoverProps, ref: React.Ref): MenuPopoverState => { const baseState = useMenuPopoverBase_unstable(props, ref); + const open = useMenuContext(ctx => ctx.open); + const setOpen = useMenuContext(ctx => ctx.setOpen); + const menuPopoverRef = useMenuContext(ctx => ctx.menuPopoverRef); + const { targetDocument } = useFluent(); + const overlayRuntime = useOverlayRuntime(targetDocument); + const useNativeRuntime = overlayRuntime.mode === 'ssr' || overlayRuntime.mode === 'native'; const state: MenuPopoverState = { ...baseState, - root: { ...baseState.root, popover: 'auto' } as MenuPopoverState['root'], + root: { + ...baseState.root, + popover: useNativeRuntime ? 'auto' : undefined, + 'data-overlay-runtime': useNativeRuntime ? 'native' : 'fallback', + 'data-open': open ? '' : undefined, + } as MenuPopoverState['root'], }; - const open = useMenuContext(ctx => ctx.open); - const setOpen = useMenuContext(ctx => ctx.setOpen); - const menuPopoverRef = useMenuContext(ctx => ctx.menuPopoverRef); - React.useEffect(() => { + if (overlayRuntime.mode !== 'native') { + return; + } + const surface = menuPopoverRef.current as HTMLElement | null; if (!surface) { @@ -71,7 +84,7 @@ export const useMenuPopover = (props: MenuPopoverProps, ref: React.Ref surface.removeEventListener('toggle', onSurfaceToggle); - }, [menuPopoverRef, open, setOpen]); + }, [menuPopoverRef, open, overlayRuntime.mode, setOpen]); return state; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/renderMenu.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Menu/renderMenu.tsx index c1ccdd1a344897..1cceb3f8b1f938 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Menu/renderMenu.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/renderMenu.tsx @@ -2,10 +2,21 @@ import * as React from 'react'; import type { JSXElement } from '@fluentui/react-utilities'; import { MenuProvider } from './menuContext'; import type { MenuState, MenuContextValues } from './Menu.types'; +import { OverlaySurfaceHost } from '../../overlayRuntime'; +import type { WithFallbackBehavior } from '../../overlayRuntime/types'; -export const renderMenu = (state: MenuState, contextValues: MenuContextValues): JSXElement => ( - - {state.menuTrigger} - {state.menuPopover as React.ReactNode} - -); +export const renderMenu = (state: MenuState, contextValues: MenuContextValues): JSXElement => { + const { fallbackBehavior } = state as WithFallbackBehavior; + + return ( + + {state.menuTrigger} + {state.menuPopover ? ( + + {state.menuPopover as React.ReactElement} + + ) : null} + {fallbackBehavior} + + ); +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/useMenu.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/useMenu.ts index ad6bdfde873d1b..c1653c1a59e0ca 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Menu/useMenu.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/useMenu.ts @@ -20,9 +20,13 @@ import type { import { useMenuContext } from './menuContext'; import { useHasParentMenuList } from './menuListPresenceContext'; import { usePositioning, resolvePositioningShorthand } from '../../positioning'; +import { useOverlayRuntime } from '../../overlayRuntime'; +import type { WithFallbackBehavior } from '../../overlayRuntime/types'; import type { MenuOpenChangeData, MenuOpenEvent, MenuProps, MenuState } from './Menu.types'; export const useMenu = (props: MenuProps): MenuState => { + const { targetDocument } = useFluent(); + const overlayRuntime = useOverlayRuntime(targetDocument); const isSubmenu = useIsSubmenu(); const { hoverDelay = 500, @@ -101,6 +105,39 @@ export const useMenu = (props: MenuProps): MenuState => { // eslint-disable-next-line @typescript-eslint/no-deprecated const mergedMenuPopoverRef = useMergedRefs(menuPopoverRef, containerRef) as React.MutableRefObject; + const fallbackBehavior = + overlayRuntime.mode === 'fallback-ready' + ? React.createElement(overlayRuntime.runtime.FallbackMenuBehavior, { + closeOnScroll, + contentRef: menuPopoverRef as React.RefObject, + onDismiss: event => { + if (event.type === 'keydown') { + setOpen(event as MenuOpenEvent, { + event: event as unknown as React.KeyboardEvent, + keyboard: true, + open: false, + type: 'menuPopoverKeyDown', + }); + } else if (event.type === 'scroll') { + setOpen(event as MenuOpenEvent, { + event: event as unknown as MouseEvent, + open: false, + type: 'scrollOutside', + }); + } else { + setOpen(event as MenuOpenEvent, { + event: event as MouseEvent | TouchEvent, + open: false, + type: 'clickOutside', + }); + } + }, + open, + targetDocument, + triggerRef: triggerRef as React.RefObject, + }) + : undefined; + return { inline, hoverDelay, @@ -123,7 +160,8 @@ export const useMenu = (props: MenuProps): MenuState => { checkedValues, onCheckedValueChange, persistOnItemClick, - } as MenuState; + fallbackBehavior, + } as WithFallbackBehavior; }; const SUBMENU_FALLBACK_POSITIONS: PositioningShorthandValue[] = [ diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.fallback.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.fallback.test.tsx new file mode 100644 index 00000000000000..498bc45313ca99 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.fallback.test.tsx @@ -0,0 +1,75 @@ +import * as React from 'react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; + +import { Popover } from './Popover'; +import { PopoverSurface } from './PopoverSurface/PopoverSurface'; +import { PopoverTrigger } from './PopoverTrigger/PopoverTrigger'; +import { + resetOverlayRuntimeForTests, + setOverlayRuntimeOverrideForTests, +} from '../../overlayRuntime'; + +describe('Popover fallback runtime', () => { + beforeEach(() => { + resetOverlayRuntimeForTests(); + setOverlayRuntimeOverrideForTests(document, 'fallback'); + }); + + afterEach(() => { + resetOverlayRuntimeForTests(); + setOverlayRuntimeOverrideForTests(document, 'native'); + }); + + it('queues an open interaction while loading and portals the fallback surface', async () => { + render( + + + + + Fallback content + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Open' })); + + const surface = await screen.findByText('Fallback content'); + expect(surface).toHaveAttribute('data-overlay-runtime', 'fallback'); + expect(surface).toHaveAttribute('open'); + expect(surface).not.toHaveAttribute('popover'); + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 5)); + }); + fireEvent.mouseDown(document.body); + fireEvent.click(document.body); + + await waitFor(() => { + expect(screen.queryByText('Fallback content')).not.toBeInTheDocument(); + }); + }); + + it('dismisses the topmost fallback popover with Escape and restores focus', async () => { + render( + + + + + + + + , + ); + + const trigger = screen.getByRole('button', { name: 'Open' }); + fireEvent.click(trigger); + + const inside = await screen.findByRole('button', { name: 'Inside' }); + inside.focus(); + fireEvent.keyDown(document, { key: 'Escape' }); + + await waitFor(() => { + expect(screen.queryByRole('button', { name: 'Inside' })).not.toBeInTheDocument(); + }); + expect(trigger).toHaveFocus(); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.internal-types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.internal-types.ts new file mode 100644 index 00000000000000..2aa9be7a73a62f --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.internal-types.ts @@ -0,0 +1,19 @@ +import type * as React from 'react'; + +import type { WithFallbackBehavior } from '../../overlayRuntime/types'; +import type { PopoverContextValue, PopoverState } from './Popover.types'; +import type { PopoverSurfaceState } from './PopoverSurface/PopoverSurface.types'; + +export type PopoverStateInternal = WithFallbackBehavior & { + positioningArrowRef: React.RefCallback; +}; + +export type PopoverContextValueInternal = PopoverContextValue & { + positioning: PopoverContextValue['positioning'] & { + arrowRef: React.RefCallback; + }; +}; + +export type PopoverSurfaceStateInternal = PopoverSurfaceState & { + renderArrowRef: React.Ref; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverSurface/renderPopoverSurface.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverSurface/renderPopoverSurface.tsx index af62eb3434e1ff..13f1963c58da2f 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverSurface/renderPopoverSurface.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverSurface/renderPopoverSurface.tsx @@ -4,13 +4,15 @@ import { assertSlots } from '@fluentui/react-utilities'; import type { PopoverSurfaceSlots, PopoverSurfaceState } from './PopoverSurface.types'; import type { JSXElement } from '@fluentui/react-utilities'; +import type { PopoverSurfaceStateInternal } from '../Popover.internal-types'; export const renderPopoverSurface = (state: PopoverSurfaceState): JSXElement => { assertSlots(state); + const { renderArrowRef } = state as unknown as PopoverSurfaceStateInternal; return ( - {state.withArrow &&
} + {state.withArrow &&
} {state.root.children} ); 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 a65e2d8ed73022..e3f1557e611cd1 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 @@ -2,9 +2,15 @@ import type * as React from 'react'; import { useMergedRefs, slot, useEventCallback } from '@fluentui/react-utilities'; +import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; import { usePopoverContext } from '../popoverContext'; import { stringifyDataAttribute } from '../../../utils'; +import { useOverlayRuntime } from '../../../overlayRuntime'; import type { PopoverSurfaceProps, PopoverSurfaceState } from './PopoverSurface.types'; +import type { + PopoverContextValueInternal, + PopoverSurfaceStateInternal, +} from '../Popover.internal-types'; /** * Returns the state for a PopoverSurface component. @@ -19,13 +25,19 @@ export const usePopoverSurface = ( const arrowRef = usePopoverContext(context => context.arrowRef); const withArrow = usePopoverContext(context => context.withArrow); const open = usePopoverContext(context => context.open); - const positioningCtx = usePopoverContext(context => context.positioning); + const positioningCtx = usePopoverContext( + context => context.positioning, + ) as PopoverContextValueInternal['positioning']; const surfaceId = usePopoverContext(context => context.surfaceId); const trapFocus = usePopoverContext(context => context.trapFocus); + const { targetDocument } = useFluent(); + const overlayRuntime = useOverlayRuntime(targetDocument); + const useNativeRuntime = overlayRuntime.mode === 'ssr' || overlayRuntime.mode === 'native'; - const state: PopoverSurfaceState = { + const state: PopoverSurfaceStateInternal = { withArrow, arrowRef, + renderArrowRef: useMergedRefs(arrowRef, positioningCtx.arrowRef), components: { root: 'dialog' }, root: slot.always( { @@ -33,8 +45,11 @@ export const usePopoverSurface = ( role: trapFocus ? 'dialog' : 'group', ...props, id: surfaceId, - popover: trapFocus ? undefined : 'auto', + 'aria-modal': !useNativeRuntime && trapFocus ? true : props['aria-modal'], + open: useNativeRuntime ? undefined : true, + popover: useNativeRuntime && !trapFocus ? 'auto' : undefined, 'data-popover-surface': '', + 'data-overlay-runtime': useNativeRuntime ? 'native' : 'fallback', 'data-open': stringifyDataAttribute(open), }, { elementType: 'dialog' }, diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/renderPopover.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Popover/renderPopover.tsx index ff656e996a8b3d..173e0becad85a9 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Popover/renderPopover.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/renderPopover.tsx @@ -1,6 +1,8 @@ import * as React from 'react'; import { PopoverProvider } from './popoverContext'; import type { PopoverState, PopoverContextValue } from './Popover.types'; +import { OverlaySurfaceHost } from '../../overlayRuntime'; +import type { PopoverStateInternal } from './Popover.internal-types'; /** * Renders the Popover component by wrapping children with the PopoverContext.Provider. @@ -8,9 +10,16 @@ import type { PopoverState, PopoverContextValue } from './Popover.types'; export const renderPopover = ( state: PopoverState, contextValues: { popover: PopoverContextValue }, -): React.ReactElement => ( - - {state.popoverTrigger} - {state.open ? state.popoverSurface : null} - -); +): React.ReactElement => { + const { fallbackBehavior } = state as PopoverStateInternal; + + return ( + + {state.popoverTrigger} + {state.open && state.popoverSurface ? ( + {state.popoverSurface} + ) : null} + {fallbackBehavior} + + ); +}; 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..2eb68beb3516c9 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 @@ -2,8 +2,14 @@ import * as React from 'react'; import { useControllableState, useEventCallback, useId, useTimeout } from '@fluentui/react-utilities'; +import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; import { usePositioning, resolvePositioningShorthand } from '../../hooks'; +import { useOverlayRuntime } from '../../overlayRuntime'; import type { PopoverProps, PopoverState, PopoverContextValue, OpenPopoverEvents } from './Popover.types'; +import type { + PopoverContextValueInternal, + PopoverStateInternal, +} from './Popover.internal-types'; const SUPPORTS_POPOVER_OPEN_SELECTOR = typeof CSS !== 'undefined' && typeof CSS.supports === 'function' && CSS.supports('selector(:popover-open)'); @@ -17,6 +23,9 @@ const isDialogElement = (el: Element | null): el is HTMLDialogElement => * Returns the state for a Popover component. */ export const usePopover = (props: PopoverProps): PopoverState => { + const { targetDocument } = useFluent(); + const overlayRuntime = useOverlayRuntime(targetDocument); + const { openOnHover = false, openOnContext = false, @@ -77,6 +86,22 @@ export const usePopover = (props: PopoverProps): PopoverState => { const surfaceId = props.id ?? generatedSurfaceId; const positioning = usePositioning(resolvePositioningShorthand(props.positioning)); + const positioningArrowRef = ( + positioning as typeof positioning & { + arrowRef: React.RefCallback; + } + ).arrowRef; + const fallbackBehavior = + overlayRuntime.mode === 'fallback-ready' + ? React.createElement(overlayRuntime.runtime.FallbackPopoverBehavior, { + contentRef, + onDismiss: event => setOpen(event as OpenPopoverEvents, false), + open, + targetDocument, + trapFocus, + triggerRef, + }) + : undefined; const onSurfaceToggle = useEventCallback((event: Event) => { const toggle = event as ToggleEvent; @@ -96,6 +121,10 @@ export const usePopover = (props: PopoverProps): PopoverState => { }); React.useEffect(() => { + if (overlayRuntime.mode !== 'native') { + return; + } + const surface = contentRef.current; if (!surface) { @@ -132,7 +161,7 @@ export const usePopover = (props: PopoverProps): PopoverState => { surface.addEventListener('toggle', onSurfaceToggle); return () => surface.removeEventListener('toggle', onSurfaceToggle); - }, [open, trapFocus, contentRef, onSurfaceToggle, onSurfaceClose]); + }, [open, trapFocus, contentRef, onSurfaceToggle, onSurfaceClose, overlayRuntime.mode]); const children = React.Children.toArray(props.children) as React.ReactElement[]; @@ -175,11 +204,14 @@ export const usePopover = (props: PopoverProps): PopoverState => { contextTarget, setContextTarget, positioning, + positioningArrowRef, surfaceId, - }; + fallbackBehavior, + } as PopoverStateInternal; }; export const usePopoverContextValues = (state: PopoverState): { popover: PopoverContextValue } => { + const { positioningArrowRef } = state as PopoverStateInternal; const { open, setOpen, @@ -210,8 +242,9 @@ export const usePopoverContextValues = (state: PopoverState): { popover: Popover positioning: { targetRef: positioning.targetRef, containerRef: positioning.containerRef, + arrowRef: positioningArrowRef, }, surfaceId, - }, + } as PopoverContextValueInternal, }; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/renderTagPicker.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/renderTagPicker.tsx index 93bdfe09d6e749..c96c064a082b0b 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/renderTagPicker.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/renderTagPicker.tsx @@ -1 +1,42 @@ -export { renderTagPicker_unstable as renderTagPicker } from '@fluentui/react-tag-picker'; +/** @jsxRuntime automatic */ +/** @jsxImportSource @fluentui/react-jsx-runtime */ + +import type * as React from 'react'; +import { ActiveDescendantContextProvider } from '@fluentui/react-aria'; +import { ListboxProvider } from '@fluentui/react-combobox'; +import { assertSlots } from '@fluentui/react-utilities'; +import type { JSXElement } from '@fluentui/react-utilities'; +import { TagPickerContextProvider } from '@fluentui/react-tag-picker'; + +import { OverlaySurfaceHost } from '../../overlayRuntime'; +import type { + TagPickerContextValues, + TagPickerSlots, + TagPickerState, +} from './TagPicker.types'; + +export const renderTagPicker = ( + state: TagPickerState, + contexts: TagPickerContextValues, +): JSXElement => { + assertSlots(state); + + return ( + + + + {state.trigger} + {state.popover ? ( + + {state.popover as React.ReactElement} + + ) : null} + + + + ); +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/renderToaster.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/renderToaster.tsx index 3a2441f4d7cfc8..6a0da479c6f902 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/renderToaster.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/renderToaster.tsx @@ -6,6 +6,7 @@ import { assertSlots } from '@fluentui/react-utilities'; import type { JSXElement } from '@fluentui/react-utilities'; import type { ToasterSlotsInternal, ToasterState } from './Toaster.types'; import { AriaLive } from '../AriaLive'; +import { OverlaySurfaceHost } from '../../../overlayRuntime'; /** * Render the position-based containers for the headless Toaster. @@ -42,7 +43,9 @@ export const renderToaster = (state: ToasterState): JSXElement => { return ( <> {ariaLive} - {hasToasts ? positionSlots : null} + {hasToasts ? ( + {positionSlots} + ) : null} ); }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/useToaster.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/useToaster.tsx index 798a49bdbbf6ec..8bcb85530ed3c3 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/useToaster.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/useToaster.tsx @@ -15,6 +15,7 @@ import { import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; import { Escape } from '@fluentui/keyboard-keys'; import { ToastContainer } from '../ToastContainer'; +import { useOverlayRuntime } from '../../../overlayRuntime'; const SUPPORTS_POPOVER_OPEN_SELECTOR = typeof CSS !== 'undefined' && typeof CSS.supports === 'function' && CSS.supports('selector(:popover-open)'); @@ -34,7 +35,9 @@ export const useToaster = (props: ToasterProps): ToasterState => { const announceRef = React.useRef(() => null); const announce = React.useCallback((message, options) => announceRef.current(message, options), []); - const { dir } = useFluent(); + const { dir, targetDocument } = useFluent(); + const overlayRuntime = useOverlayRuntime(targetDocument); + const useNativeRuntime = overlayRuntime.mode === 'ssr' || overlayRuntime.mode === 'native'; const { onKeyDown: onKeyDownProp, ...rootProps } = slot.always( getIntrinsicElementProps>>('div', rest), @@ -60,7 +63,7 @@ export const useToaster = (props: ToasterProps): ToasterState => { // position has no more toasts, the slot unmounts and the browser removes // it from the top layer automatically — no explicit hidePopover needed. useIsomorphicLayoutEffect(() => { - if (!positionHasToasts) { + if (!positionHasToasts || overlayRuntime.mode !== 'native') { return; } const el = popoverRef.current; @@ -71,7 +74,7 @@ export const useToaster = (props: ToasterProps): ToasterState => { if (!isOpen) { el.showPopover(); } - }, [positionHasToasts]); + }, [positionHasToasts, overlayRuntime.mode]); return slot.optional>>(positionHasToasts ? {} : null, { defaultProps: { @@ -89,10 +92,14 @@ export const useToaster = (props: ToasterProps): ToasterState => { )), onKeyDown, - popover: 'manual' as const, - 'data-toaster-position': toastPosition, role: 'list', ...rootProps, + popover: useNativeRuntime ? ('manual' as const) : undefined, + 'data-overlay-runtime': useNativeRuntime ? 'native' : 'fallback', + 'data-toaster-position': toastPosition, + style: useNativeRuntime + ? rootProps.style + : { ...rootProps.style, zIndex: 1000000 }, } as ExtractSlotProps>, elementType: 'div', }); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/renderTooltip.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/renderTooltip.tsx index 4e8d795e2ae94c..97ffc1f28c1109 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/renderTooltip.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/renderTooltip.tsx @@ -4,22 +4,28 @@ import { assertSlots } from '@fluentui/react-utilities'; import type { JSXElement } from '@fluentui/react-utilities'; import type { TooltipState, TooltipSlots } from './Tooltip.types'; +import { OverlaySurfaceHost } from '../../overlayRuntime'; +import type { WithFallbackBehavior } from '../../overlayRuntime/types'; /** * Render the final JSX of Tooltip. */ export const renderTooltip = (state: TooltipState): JSXElement => { assertSlots(state); + const { fallbackBehavior } = state as WithFallbackBehavior; return ( <> {state.children} {state.shouldRenderTooltip && ( - - {state.withArrow &&
} - {state.content.children} - + + + {state.withArrow &&
} + {state.content.children} + + )} + {fallbackBehavior} ); }; 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 5fe5f4204bf110..e00f3c6e716dbb 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 @@ -21,6 +21,8 @@ import { KEYBORG_FOCUSIN, useIsNavigatingWithKeyboard } from '@fluentui/react-ta import type { OnVisibleChangeData, TooltipProps, TooltipState, TooltipTriggerProps } from './Tooltip.types'; import { resolvePositioningShorthand, usePositioning } from '../../positioning'; +import { useOverlayRuntime } from '../../overlayRuntime'; +import type { WithFallbackBehavior } from '../../overlayRuntime/types'; /** * Create the state required to render Tooltip. @@ -30,6 +32,8 @@ import { resolvePositioningShorthand, usePositioning } from '../../positioning'; export const useTooltip = (props: TooltipProps): TooltipState => { const isServerSideRender = useIsSSR(); const { targetDocument } = useFluent(); + const overlayRuntime = useOverlayRuntime(targetDocument); + const useNativeRuntime = overlayRuntime.mode === 'ssr' || overlayRuntime.mode === 'native'; const [visible, setVisibleInternal] = useControllableState({ state: props.visible, initialState: false }); @@ -59,18 +63,33 @@ export const useTooltip = (props: TooltipProps): TooltipState => { content: slot.always(content, { defaultProps: { role: 'tooltip', - popover: 'hint', + hidden: useNativeRuntime || visible ? undefined : true, + popover: useNativeRuntime ? 'hint' : undefined, }, elementType: 'div', }), }; + state.content.popover = useNativeRuntime ? 'hint' : undefined; + state.content.hidden = useNativeRuntime || visible ? undefined : true; + ( + state.content as TooltipState['content'] & { + 'data-overlay-runtime'?: 'native' | 'fallback'; + } + )['data-overlay-runtime'] = useNativeRuntime ? 'native' : 'fallback'; const positioningOptions = resolvePositioningShorthand(positioning); - const { targetRef, containerRef } = usePositioning(positioningOptions); + const { targetRef, containerRef, arrowRef } = usePositioning( + positioningOptions, + ) as ReturnType & { + arrowRef: React.RefCallback; + }; + state.arrowRef = arrowRef as React.Ref; state.content.id = useId('tooltip-', state.content.id); - const contentRef = useMergedRefs(state.content.ref, containerRef); + const contentElementRef = React.useRef(null); + const triggerElementRef = React.useRef(null); + const contentRef = useMergedRefs(state.content.ref, containerRef, contentElementRef); state.content.ref = contentRef; const [setDelayTimeout, clearDelayTimeout] = useTimeout(); @@ -97,6 +116,10 @@ export const useTooltip = (props: TooltipProps): TooltipState => { // Keep the tooltip in sync with the state when it is changed programmatically. // Also sync React state when the browser auto-dismisses the hint popover (click outside, Escape). useIsomorphicLayoutEffect(() => { + if (overlayRuntime.mode !== 'native') { + return; + } + const el = contentRef.current; if (!el) { return; @@ -115,8 +138,8 @@ export const useTooltip = (props: TooltipProps): TooltipState => { // eslint-disable-next-line no-console console.warn( [ - 'Popover API is not supported in this browser, and the tooltip will not work correctly.', - 'Please include a popover polyfill for better browser support.', + 'The native Popover API failed after overlay capability detection.', + 'The tooltip could not synchronize its native open state.', ].join(' '), { error }, ); @@ -126,7 +149,7 @@ export const useTooltip = (props: TooltipProps): TooltipState => { return () => { el.removeEventListener('toggle', onToggle); }; - }, [contentRef, visible, setVisible, onToggle]); + }, [contentRef, onToggle, overlayRuntime.mode, setVisible, visible]); // Used to skip showing the tooltip in certain situations when the trigger is focused. // See comments where this is set for more info. @@ -252,6 +275,7 @@ export const useTooltip = (props: TooltipProps): TooltipState => { ref: useMergedRefs( getReactElementRef(child), keyborgListenerCallbackRef, + triggerElementRef, // If the target prop is not provided, attach targetRef to the trigger element's ref prop positioningOptions.target === undefined ? targetRef : undefined, ), @@ -265,5 +289,16 @@ export const useTooltip = (props: TooltipProps): TooltipState => { onBlur: useEventCallback(mergeCallbacks(child?.props?.onBlur, onLeaveTrigger)), }); + (state as WithFallbackBehavior).fallbackBehavior = + overlayRuntime.mode === 'fallback-ready' + ? React.createElement(overlayRuntime.runtime.FallbackTooltipBehavior, { + contentRef: contentElementRef, + onDismiss: () => setVisible(undefined, { visible: false }), + targetDocument, + triggerRef: triggerElementRef, + visible, + }) + : undefined; + return state; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/index.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/index.ts index f2d52d9a89aa0a..5aff1155d628da 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/index.ts +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/index.ts @@ -1,6 +1,5 @@ -export { resolvePositioningShorthand } from '@fluentui/react-positioning'; export { usePositioning } from './usePositioning'; -export { getPlacementString } from './utils'; +export { getPlacementString, resolvePositioningShorthand } from './utils'; export { POSITIONS, ALIGNMENTS } from './constants'; export type { PositioningProps, PositioningReturn } from './types'; export type { 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..3974c880add35d 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,8 +1,22 @@ import * as React from 'react'; import { act, render } from '@testing-library/react'; import { usePositioning } from './usePositioning'; -import { getPlacementString } from './utils/placement'; +import { + getDefaultFallbackPositions, + getLogicalPlacement, + getPlacementString, +} from './utils/placement'; import type { PositioningProps, PositioningReturn } from './types'; +import { + resetOverlayRuntimeForTests, + setOverlayFallbackLoaderForTests, +} from '../../overlayRuntime'; +import { setOverlayRuntimeOverrideForTests } from '../../overlayRuntime/nativeCapabilities'; +import type { OverlayFallbackRuntime } from '../../overlayRuntime/fallbackRuntime'; + +type PositioningReturnInternal = PositioningReturn & { + arrowRef: React.RefCallback; +}; function mountHook(options: PositioningProps = {}) { const resultRef = React.createRef<{ current: PositioningReturn }>(); @@ -16,11 +30,12 @@ function mountHook(options: PositioningProps = {}) { } describe('usePositioning', () => { - it('returns targetRef and containerRef callbacks', () => { - const result = mountHook(); + it('returns targetRef and containerRef callbacks with an internal arrow ref', () => { + const result = mountHook() as { current: PositioningReturnInternal }; expect(typeof result.current.targetRef).toBe('function'); expect(typeof result.current.containerRef).toBe('function'); + expect(typeof result.current.arrowRef).toBe('function'); }); it('targetRef writes anchor-name onto the trigger element', () => { @@ -245,3 +260,126 @@ describe('getPlacementString', () => { expect(getPlacementString('after', 'end')).toBe('after-bottom'); }); }); + +describe('native fallback placement mapping', () => { + it('maps the default block and inline flip tactics in order', () => { + expect(getDefaultFallbackPositions('above', 'start')).toEqual([ + 'below-start', + 'above-end', + 'below-end', + ]); + expect(getDefaultFallbackPositions('before', 'start')).toEqual([ + 'before-bottom', + 'after-top', + 'after-bottom', + ]); + }); + + it('omits duplicate no-op flips for centered placements', () => { + expect(getDefaultFallbackPositions('above', 'center')).toEqual(['below']); + expect(getDefaultFallbackPositions('before', 'center')).toEqual(['after']); + }); + + it('maps Floating UI physical placement back to logical Headless placement', () => { + expect(getLogicalPlacement('top-start', 'ltr')).toBe('above-start'); + expect(getLogicalPlacement('right-end', 'ltr')).toBe('after-bottom'); + expect(getLogicalPlacement('right-end', 'rtl')).toBe('before-bottom'); + expect(getLogicalPlacement('left-start', 'rtl')).toBe('after-top'); + }); +}); + +describe('fallback positioning backend', () => { + afterEach(() => { + resetOverlayRuntimeForTests(); + setOverlayRuntimeOverrideForTests(document, 'native'); + }); + + it('loads the imperative manager with native-normalized behavior', async () => { + resetOverlayRuntimeForTests(); + setOverlayRuntimeOverrideForTests(document, 'fallback'); + + const manager = { + dispose: jest.fn(), + updatePosition: jest.fn(), + }; + type ManagerOptions = Parameters< + OverlayFallbackRuntime['positioning']['createPositioningManager_unstable'] + >[0]; + const createPositioningManager = jest.fn((_options: ManagerOptions) => manager); + const fallbackRuntime = { + positioning: { + createPositioningManager_unstable: createPositioningManager, + }, + } as unknown as OverlayFallbackRuntime; + + setOverlayFallbackLoaderForTests(() => + Promise.resolve({ + fallbackRuntime, + } as typeof import('../../overlayRuntime/fallbackRuntime')), + ); + + const positioningRef = React.createRef<{ + updatePosition: () => void; + setTarget: (el: HTMLElement | null) => void; + }>(); + let positioningResult: PositioningReturnInternal | undefined; + const Capture = () => { + positioningResult = usePositioning({ + align: 'start', + position: 'above', + positioningRef: positioningRef as PositioningProps['positioningRef'], + }) as PositioningReturnInternal; + return null; + }; + + const { unmount } = render(); + const target = document.createElement('button'); + const container = document.createElement('div'); + const arrow = document.createElement('div'); + + act(() => { + positioningResult?.targetRef(target); + positioningResult?.containerRef(container); + positioningResult?.arrowRef(arrow); + }); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(createPositioningManager).toHaveBeenCalledTimes(1); + expect(createPositioningManager).toHaveBeenCalledWith( + expect.objectContaining({ + align: 'start', + arrow, + container, + fallbackPositions: ['below-start', 'above-end', 'below-end'], + position: 'above', + strategy: 'fixed', + target, + unstable_disableShift: true, + unstable_flipFallbackStrategy: 'initialPlacement', + useTransform: false, + }), + ); + + const managerOptions = createPositioningManager.mock.calls[0][0]; + managerOptions.onPositioningEnd?.( + new CustomEvent('fui-positioningend', { + detail: { + escaped: false, + placement: 'right-end', + referenceHidden: false, + }, + }), + ); + expect(container).toHaveAttribute('data-placement', 'after-bottom'); + + positioningRef.current?.updatePosition(); + expect(manager.updatePosition).toHaveBeenCalledTimes(1); + + unmount(); + expect(manager.dispose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.ts index e577ae1f96993f..61b22b92319a3f 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.ts +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.ts @@ -10,9 +10,15 @@ import type { } from '@fluentui/react-positioning'; import type { PositioningProps, PositioningReturn } from './types'; import { POSITIONS, ALIGNMENTS, POSITION_AREA_MAP } from './constants'; -import { getPlacementString, normalizeAlign } from './utils/placement'; +import { + getDefaultFallbackPositions, + getLogicalPlacement, + getPlacementString, + normalizeAlign, +} from './utils/placement'; import { applyOffset, getCoverSelfAlignment, resolveElementRef, resolveOffset, shorthandToPositionArea } from './utils'; import { usePlacementObserver } from './usePlacementObserver'; +import { useOverlayRuntime } from '../../overlayRuntime'; export type TargetElement = HTMLElement | PositioningVirtualElement; @@ -56,18 +62,33 @@ export function usePositioning(options: PositioningProps): PositioningReturn { 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 effectiveTarget = imperativeTarget ?? resolveElementRef(customTarget) ?? triggerEl; + const fallbackManagerRef = React.useRef<{ updatePosition: () => void; dispose: () => void } | null>(null); const anchorName = `--${useId('popover-anchor-')}`; const positionArea = POSITION_AREA_MAP[position][align]; const placement = getPlacementString(position, align); - const { targetDocument } = useFluent(); + const { dir, targetDocument } = useFluent(); + const overlayRuntime = useOverlayRuntime(targetDocument); const fallbackAreas = React.useMemo(() => fallbackPositions.map(shorthandToPositionArea), [fallbackPositions]); + const fallbackPlacements = React.useMemo( + () => + fallbackPositions.length > 0 + ? fallbackPositions + : getDefaultFallbackPositions(position, align), + [align, fallbackPositions, position], + ); - const requestPlacementUpdate = usePlacementObserver(containerEl, effectiveTarget, targetDocument, coverTarget); + const requestPlacementUpdate = usePlacementObserver( + containerEl, + effectiveTarget, + targetDocument, + coverTarget || overlayRuntime.mode !== 'native', + ); React.useImperativeHandle( positioningRef, @@ -75,13 +96,19 @@ export function usePositioning(options: PositioningProps): PositioningReturn { setTarget: (el: TargetElement | null) => { setImperativeTarget(resolveElementRef(el)); }, - updatePosition: requestPlacementUpdate, + updatePosition: () => { + if (overlayRuntime.mode === 'fallback-ready') { + fallbackManagerRef.current?.updatePosition(); + } else { + requestPlacementUpdate(); + } + }, }), - [requestPlacementUpdate], + [overlayRuntime.mode, requestPlacementUpdate], ); useIsomorphicLayoutEffect(() => { - if (!effectiveTarget) { + if (overlayRuntime.mode !== 'native' || !effectiveTarget) { return; } @@ -107,17 +134,93 @@ export function usePositioning(options: PositioningProps): PositioningReturn { } } }; - }, [effectiveTarget, anchorName]); + }, [anchorName, effectiveTarget, overlayRuntime.mode]); + + useIsomorphicLayoutEffect(() => { + if ( + overlayRuntime.mode !== 'fallback-ready' || + !effectiveTarget || + !containerEl + ) { + return; + } + + containerEl.setAttribute('data-placement', placement); + const previousZIndex = containerEl.style.zIndex; + const previousVisibility = containerEl.style.visibility; + if (!previousZIndex) { + containerEl.style.zIndex = '1000000'; + } + containerEl.style.visibility = 'hidden'; + + const manager = overlayRuntime.runtime.positioning.createPositioningManager_unstable({ + target: effectiveTarget, + container: containerEl, + arrow: arrowEl, + dir, + targetDocument, + align: alignInput, + position, + fallbackPositions: fallbackPlacements, + offset: { mainAxis, crossAxis }, + coverTarget, + strategy, + matchTargetSize, + pinned: pinned || coverTarget, + useTransform: false, + unstable_disableShift: true, + unstable_flipFallbackStrategy: 'initialPlacement', + onPositioningEnd: event => { + const resolvedPlacement = getLogicalPlacement(event.detail.placement, dir); + if (resolvedPlacement) { + containerEl.setAttribute('data-placement', resolvedPlacement); + } + containerEl.style.visibility = previousVisibility; + }, + }); + + fallbackManagerRef.current = manager; + + return () => { + manager.dispose(); + containerEl.style.zIndex = previousZIndex; + containerEl.style.visibility = previousVisibility; + if (fallbackManagerRef.current === manager) { + fallbackManagerRef.current = null; + } + }; + }, [ + alignInput, + arrowEl, + containerEl, + coverTarget, + crossAxis, + dir, + effectiveTarget, + fallbackPlacements, + mainAxis, + matchTargetSize, + overlayRuntime, + pinned, + placement, + position, + strategy, + targetDocument, + ]); 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); - if (!node) { + if (!node || overlayRuntime.mode !== 'native') { return; } @@ -184,8 +287,11 @@ export function usePositioning(options: PositioningProps): PositioningReturn { coverAlignment, strategy, matchTargetSize, + overlayRuntime.mode, ], ); - return { targetRef, containerRef }; + return { targetRef, containerRef, arrowRef } as PositioningReturn & { + arrowRef: React.RefCallback; + }; } diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/index.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/index.ts index 8506fa430c6121..50525994b74969 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/index.ts +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/index.ts @@ -2,5 +2,12 @@ export { computePosition } from './computePosition'; export type { ComputePositionConfig, ComputePositionReturn } from './computePosition'; export { debounce } from './debounce'; export { applyOffset, resolveOffset } from './offset'; -export { getCoverSelfAlignment, getPlacementString, shorthandToPositionArea } from './placement'; +export { + getCoverSelfAlignment, + getDefaultFallbackPositions, + getLogicalPlacement, + getPlacementString, + shorthandToPositionArea, +} from './placement'; export { resolveElementRef } from './resolveElementRef'; +export { resolvePositioningShorthand } from './resolvePositioningShorthand'; diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/placement.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/placement.ts index 812e5919fd1238..8e6975909df648 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/placement.ts +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/placement.ts @@ -1,7 +1,7 @@ -import { resolvePositioningShorthand } from '@fluentui/react-positioning'; import type { Alignment, Position, PositioningShorthandValue } from '@fluentui/react-positioning'; import type { LogicalAlignment } from '../types'; import { ALIGNMENTS, POSITIONS, POSITION_AREA_MAP } from '../constants'; +import { resolvePositioningShorthand } from './resolvePositioningShorthand'; const ALIGN_ALIASES: Record = { top: ALIGNMENTS.start, @@ -41,6 +41,94 @@ export function shorthandToPositionArea(shorthand: PositioningShorthandValue): s return POSITION_AREA_MAP[position][normalizeAlign(align)]; } +const flipBlock = ( + position: Position, + align: LogicalAlignment, +): { position: Position; align: LogicalAlignment } => { + if (position === POSITIONS.above) { + return { position: POSITIONS.below, align }; + } + + if (position === POSITIONS.below) { + return { position: POSITIONS.above, align }; + } + + return { + position, + align: align === ALIGNMENTS.start ? ALIGNMENTS.end : align === ALIGNMENTS.end ? ALIGNMENTS.start : align, + }; +}; + +const flipInline = ( + position: Position, + align: LogicalAlignment, +): { position: Position; align: LogicalAlignment } => { + if (position === POSITIONS.before) { + return { position: POSITIONS.after, align }; + } + + if (position === POSITIONS.after) { + return { position: POSITIONS.before, align }; + } + + return { + position, + align: align === ALIGNMENTS.start ? ALIGNMENTS.end : align === ALIGNMENTS.end ? ALIGNMENTS.start : align, + }; +}; + +export function getDefaultFallbackPositions( + position: Position, + align: LogicalAlignment, +): PositioningShorthandValue[] { + const primary = getPlacementString(position, align); + const block = flipBlock(position, align); + const inline = flipInline(position, align); + const both = flipInline(block.position, block.align); + const seen = new Set([primary]); + + return [block, inline, both] + .map(candidate => getPlacementString(candidate.position, candidate.align)) + .filter(candidate => { + if (seen.has(candidate)) { + return false; + } + + seen.add(candidate); + return true; + }); +} + +export function getLogicalPlacement( + placement: string, + dir: 'ltr' | 'rtl', +): PositioningShorthandValue | undefined { + const [side, alignment] = placement.split('-'); + let position: Position; + + if (side === 'top') { + position = POSITIONS.above; + } else if (side === 'bottom') { + position = POSITIONS.below; + } else if (side === 'left') { + position = dir === 'rtl' ? POSITIONS.after : POSITIONS.before; + } else if (side === 'right') { + position = dir === 'rtl' ? POSITIONS.before : POSITIONS.after; + } else { + return undefined; + } + + if (!alignment) { + return position; + } + + if (position === POSITIONS.before || position === POSITIONS.after) { + return `${position}-${alignment === 'start' ? 'top' : 'bottom'}`; + } + + return `${position}-${alignment === 'start' ? 'start' : 'end'}`; +} + export function getCoverSelfAlignment( position: Position, align: LogicalAlignment, diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/resolvePositioningShorthand.test.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/resolvePositioningShorthand.test.ts new file mode 100644 index 00000000000000..a6c488d9cc4582 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/resolvePositioningShorthand.test.ts @@ -0,0 +1,25 @@ +import { resolvePositioningShorthand } from './resolvePositioningShorthand'; + +describe('resolvePositioningShorthand', () => { + it('resolves string shorthands', () => { + expect(resolvePositioningShorthand('below-start')).toEqual({ + position: 'below', + align: 'start', + }); + expect(resolvePositioningShorthand('before-bottom')).toEqual({ + position: 'before', + align: 'bottom', + }); + }); + + it('returns object shorthands unchanged', () => { + const positioning = { position: 'after', align: 'top' } as const; + + expect(resolvePositioningShorthand(positioning)).toBe(positioning); + }); + + it('resolves an empty value to an empty object', () => { + expect(resolvePositioningShorthand(undefined)).toEqual({}); + expect(resolvePositioningShorthand(null)).toEqual({}); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/resolvePositioningShorthand.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/resolvePositioningShorthand.ts new file mode 100644 index 00000000000000..e8b7d4c8d687dc --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/resolvePositioningShorthand.ts @@ -0,0 +1,34 @@ +import type { + PositioningProps, + PositioningShorthand, + PositioningShorthandValue, +} from '@fluentui/react-positioning'; + +const shorthandLookup: Record> = { + above: { position: 'above', align: 'center' }, + 'above-start': { position: 'above', align: 'start' }, + 'above-end': { position: 'above', align: 'end' }, + below: { position: 'below', align: 'center' }, + 'below-start': { position: 'below', align: 'start' }, + 'below-end': { position: 'below', align: 'end' }, + before: { position: 'before', align: 'center' }, + 'before-top': { position: 'before', align: 'top' }, + 'before-bottom': { position: 'before', align: 'bottom' }, + after: { position: 'after', align: 'center' }, + 'after-top': { position: 'after', align: 'top' }, + 'after-bottom': { position: 'after', align: 'bottom' }, +}; + +export function resolvePositioningShorthand( + shorthand: PositioningShorthand | undefined | null, +): Readonly { + if (shorthand === undefined || shorthand === null) { + return {}; + } + + if (typeof shorthand === 'string') { + return shorthandLookup[shorthand]; + } + + return shorthand; +} diff --git a/packages/react-components/react-headless-components-preview/library/src/overlayRuntime/OverlaySurfaceHost.tsx b/packages/react-components/react-headless-components-preview/library/src/overlayRuntime/OverlaySurfaceHost.tsx new file mode 100644 index 00000000000000..cad421f080f1e8 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/overlayRuntime/OverlaySurfaceHost.tsx @@ -0,0 +1,59 @@ +'use client'; + +import * as React from 'react'; +import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; +import type { PortalProps } from '@fluentui/react-portal'; + +import { useOverlayRuntime } from './overlayRuntime'; + +export type OverlaySurfaceHostProps = { + active: boolean; + children: React.ReactElement; + fallbackChildren?: React.ReactElement; + inline?: boolean; + keepMountedWhenInactive?: boolean; + mountNode?: PortalProps['mountNode']; +}; + +export const OverlaySurfaceHost = (props: OverlaySurfaceHostProps): React.ReactElement | null => { + const { + active, + children, + fallbackChildren = children, + inline = false, + keepMountedWhenInactive = false, + mountNode, + } = props; + const { targetDocument } = useFluent(); + const overlayRuntime = useOverlayRuntime(targetDocument); + + if (overlayRuntime.mode === 'ssr' || overlayRuntime.mode === 'native') { + return children; + } + + if (overlayRuntime.mode === 'fallback-ready') { + if (!active && !keepMountedWhenInactive) { + return null; + } + + return inline + ? fallbackChildren + : React.createElement( + overlayRuntime.runtime.Portal, + { mountNode }, + fallbackChildren, + ); + } + + if (overlayRuntime.mode === 'fallback-error') { + if (active) { + throw overlayRuntime.error; + } + + return keepMountedWhenInactive ? children : null; + } + + return active ? null : keepMountedWhenInactive ? children : null; +}; + +OverlaySurfaceHost.displayName = 'OverlaySurfaceHost'; diff --git a/packages/react-components/react-headless-components-preview/library/src/overlayRuntime/components.fallback.test.tsx b/packages/react-components/react-headless-components-preview/library/src/overlayRuntime/components.fallback.test.tsx new file mode 100644 index 00000000000000..f44cb2853fe6ac --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/overlayRuntime/components.fallback.test.tsx @@ -0,0 +1,141 @@ +import * as React from 'react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { useToastController } from '@fluentui/react-toast'; + +import { Dialog } from '../components/Dialog/Dialog'; +import { DialogSurface } from '../components/Dialog/DialogSurface/DialogSurface'; +import { Dropdown } from '../components/Dropdown/Dropdown'; +import { Option } from '../components/Dropdown/Option/Option'; +import { Menu } from '../components/Menu/Menu'; +import { MenuItem } from '../components/Menu/MenuItem/MenuItem'; +import { MenuList } from '../components/Menu/MenuList/MenuList'; +import { MenuPopover } from '../components/Menu/MenuPopover/MenuPopover'; +import { MenuTrigger } from '../components/Menu/MenuTrigger/MenuTrigger'; +import { Tooltip } from '../components/Tooltip/Tooltip'; +import { Toaster } from '../components/Toast/Toaster/Toaster'; +import { + resetOverlayRuntimeForTests, + setOverlayRuntimeOverrideForTests, +} from './index'; + +const waitForOutsideListener = async () => { + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 5)); + }); +}; + +describe('overlay component fallback runtime', () => { + beforeEach(() => { + resetOverlayRuntimeForTests(); + setOverlayRuntimeOverrideForTests(document, 'fallback'); + }); + + afterEach(() => { + resetOverlayRuntimeForTests(); + setOverlayRuntimeOverrideForTests(document, 'native'); + }); + + it('portals and dismisses Menu', async () => { + render( + + + + + + + Menu item + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Menu trigger' })); + const item = await screen.findByRole('menuitem', { name: 'Menu item' }); + expect(item.closest('[data-overlay-runtime="fallback"]')).not.toBeNull(); + + await waitForOutsideListener(); + fireEvent.mouseDown(document.body); + fireEvent.click(document.body); + + await waitFor(() => { + expect(screen.queryByRole('menuitem', { name: 'Menu item' })).not.toBeInTheDocument(); + }); + }); + + it('coordinates Tooltip visibility and Escape', async () => { + render( + + + , + ); + + fireEvent.pointerEnter(screen.getByRole('button', { name: 'Tooltip trigger' })); + const tooltip = await screen.findByRole('tooltip'); + expect(tooltip).toHaveAttribute('data-overlay-runtime', 'fallback'); + + fireEvent.keyDown(document, { key: 'Escape' }); + await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()); + }); + + it('portals Dropdown listbox and dismisses outside', async () => { + render( + + + , + ); + + const listbox = await screen.findByRole('listbox'); + expect(listbox).toHaveAttribute('data-overlay-runtime', 'fallback'); + + await waitForOutsideListener(); + fireEvent.mouseDown(document.body); + fireEvent.click(document.body); + + await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument()); + }); + + it('renders Dialog with a fallback backdrop and closes on Escape', async () => { + render( + + + + + , + ); + + const dialog = await screen.findByRole('dialog'); + expect(dialog).toHaveAttribute('data-overlay-runtime', 'fallback'); + expect(document.querySelector('[data-overlay-fallback-backdrop]')).not.toBeNull(); + + fireEvent.keyDown(document, { key: 'Escape' }); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + it('portals Toaster position containers without the Popover API', async () => { + const DispatchToast = () => { + const { dispatchToast } = useToastController(); + + React.useEffect(() => { + const timeout = setTimeout(() => { + dispatchToast(
Fallback toast
, { toastId: 'fallback-toast' }); + }); + + return () => clearTimeout(timeout); + }, [dispatchToast]); + + return null; + }; + + render( + <> + + + , + ); + + const toast = await screen.findByText('Fallback toast'); + const positionContainer = toast.closest('[data-toaster-position]'); + expect(positionContainer).toHaveAttribute('data-overlay-runtime', 'fallback'); + expect(positionContainer).not.toHaveAttribute('popover'); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/overlayRuntime/fallbackRuntime.ts b/packages/react-components/react-headless-components-preview/library/src/overlayRuntime/fallbackRuntime.ts new file mode 100644 index 00000000000000..fd9c6aec853087 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/overlayRuntime/fallbackRuntime.ts @@ -0,0 +1,429 @@ +import * as React from 'react'; +import { Escape } from '@fluentui/keyboard-keys'; +import * as positioning from '@fluentui/react-positioning'; +import { Portal } from '@fluentui/react-portal'; +import { + elementContains, + useEventCallback, + useIsomorphicLayoutEffect, + useOnClickOutside, + useOnScrollOutside, +} from '@fluentui/react-utilities'; +import { useActivateModal, useFocusFinders, useModalAttributes } from '@fluentui/react-tabster'; +import { lockDocumentScroll, unlockDocumentScroll } from '../components/Dialog/utils/scroll'; + +type ElementRef = React.RefObject; + +type DismissBehaviorProps = { + contentRef: ElementRef; + open: boolean; + targetDocument: Document | undefined; + triggerRef?: ElementRef; + closeOnScroll?: boolean; + onDismiss: (event: Event) => void; +}; + +type AutoOverlayEntry = { + content: HTMLElement; + dismiss: (event: Event) => void; + trigger: HTMLElement | null; +}; + +const autoOverlayStacks = new WeakMap(); +const dialogStacks = new WeakMap(); +const tooltipEntries = new WeakMap void }>(); + +const getAutoOverlayStack = (targetDocument: Document): AutoOverlayEntry[] => { + const existingStack = autoOverlayStacks.get(targetDocument); + if (existingStack) { + return existingStack; + } + + const stack: AutoOverlayEntry[] = []; + autoOverlayStacks.set(targetDocument, stack); + return stack; +}; + +const isNestedEntry = (parent: AutoOverlayEntry, child: AutoOverlayEntry): boolean => + elementContains(parent.content, child.trigger) || elementContains(parent.content, child.content); + +const useAutoOverlayStack = ( + props: Pick, +): void => { + const { contentRef, onDismiss, open, targetDocument, triggerRef } = props; + + useIsomorphicLayoutEffect(() => { + const content = contentRef.current; + if (!open || !content || !targetDocument) { + return; + } + + const stack = getAutoOverlayStack(targetDocument); + const entry: AutoOverlayEntry = { + content, + dismiss: onDismiss, + trigger: triggerRef?.current ?? null, + }; + + const peerDismissEvent = new Event('overlayPeerDismiss'); + for (const existingEntry of [...stack]) { + if (!isNestedEntry(existingEntry, entry) && !isNestedEntry(entry, existingEntry)) { + existingEntry.dismiss(peerDismissEvent); + } + } + + stack.push(entry); + + return () => { + const index = stack.indexOf(entry); + if (index !== -1) { + stack.splice(index, 1); + } + }; + }, [contentRef, onDismiss, open, targetDocument, triggerRef]); +}; + +const useDismissBehavior = (props: DismissBehaviorProps): void => { + const { + closeOnScroll = false, + contentRef, + onDismiss, + open, + targetDocument, + triggerRef, + } = props; + + useOnClickOutside({ + contains: elementContains, + element: targetDocument, + callback: onDismiss, + refs: triggerRef ? [triggerRef, contentRef] : [contentRef], + disabled: !open, + }); + + useOnScrollOutside({ + contains: elementContains, + element: targetDocument, + callback: onDismiss, + refs: triggerRef ? [triggerRef, contentRef] : [contentRef], + disabled: !open || !closeOnScroll, + }); + + const onKeyDown = useEventCallback((event: KeyboardEvent) => { + if (event.key !== Escape || event.defaultPrevented || !contentRef.current || !targetDocument) { + return; + } + + const stack = getAutoOverlayStack(targetDocument); + if (stack.at(-1)?.content !== contentRef.current) { + return; + } + + event.preventDefault(); + onDismiss(event); + }); + + React.useEffect(() => { + if (!open || !targetDocument) { + return; + } + + targetDocument.addEventListener('keydown', onKeyDown, true); + return () => targetDocument.removeEventListener('keydown', onKeyDown, true); + }, [onKeyDown, open, targetDocument]); +}; + +const setAttributes = ( + element: HTMLElement, + attributes: Record, +): (() => void) => { + const previousValues = new Map(); + + for (const [name, value] of Object.entries(attributes)) { + previousValues.set(name, element.getAttribute(name)); + + if (value === undefined || value === null || value === false) { + element.removeAttribute(name); + } else { + element.setAttribute(name, String(value)); + } + } + + return () => { + for (const [name, value] of previousValues) { + if (value === null) { + element.removeAttribute(name); + } else { + element.setAttribute(name, value); + } + } + }; +}; + +type FocusBehaviorProps = { + contentRef: ElementRef; + open: boolean; + targetDocument: Document | undefined; + trapFocus: boolean; + triggerRef?: ElementRef; +}; + +const useFocusBehavior = (props: FocusBehaviorProps): void => { + const { contentRef, open, targetDocument, trapFocus, triggerRef } = props; + const { modalAttributes, triggerAttributes } = useModalAttributes({ + trapFocus, + legacyTrapFocus: false, + alwaysFocusable: !trapFocus, + }); + const { findFirstFocusable } = useFocusFinders(); + const activateModal = useActivateModal(); + const previouslyFocusedRef = React.useRef(null); + + useIsomorphicLayoutEffect(() => { + const content = contentRef.current; + if (!open || !content) { + return; + } + + previouslyFocusedRef.current = targetDocument?.activeElement as HTMLElement | null; + const restoreContentAttributes = setAttributes( + content, + modalAttributes as unknown as Record, + ); + const restoreTriggerAttributes = triggerRef?.current + ? setAttributes( + triggerRef.current, + triggerAttributes as unknown as Record, + ) + : undefined; + + const autofocusTarget = content.querySelector('[autofocus]'); + const focusTarget = autofocusTarget ?? (trapFocus ? findFirstFocusable(content) : null); + focusTarget?.focus(); + + if (trapFocus && focusTarget === content) { + activateModal(content); + } + + return () => { + restoreContentAttributes(); + restoreTriggerAttributes?.(); + + const restoreTarget = triggerRef?.current ?? previouslyFocusedRef.current; + if ( + restoreTarget && + targetDocument?.contains(restoreTarget) && + (!targetDocument.activeElement || + targetDocument.activeElement === targetDocument.body || + elementContains(content, targetDocument.activeElement)) + ) { + restoreTarget.focus(); + } + previouslyFocusedRef.current = null; + }; + }, [ + activateModal, + contentRef, + findFirstFocusable, + modalAttributes, + open, + targetDocument, + trapFocus, + triggerAttributes, + triggerRef, + ]); +}; + +export type FallbackPopoverBehaviorProps = DismissBehaviorProps & { + trapFocus: boolean; +}; + +export const FallbackPopoverBehavior = (props: FallbackPopoverBehaviorProps): null => { + useAutoOverlayStack(props); + useDismissBehavior(props); + useFocusBehavior(props); + return null; +}; + +FallbackPopoverBehavior.displayName = 'FallbackPopoverBehavior'; + +export type FallbackMenuBehaviorProps = DismissBehaviorProps; + +export const FallbackMenuBehavior = (props: FallbackMenuBehaviorProps): null => { + useAutoOverlayStack(props); + useDismissBehavior(props); + + useIsomorphicLayoutEffect(() => { + if (!props.open) { + return; + } + + props.contentRef.current + ?.querySelector( + ':is([role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]):not([aria-disabled="true"])', + ) + ?.focus(); + }, [props.contentRef, props.open]); + + return null; +}; + +FallbackMenuBehavior.displayName = 'FallbackMenuBehavior'; + +export type FallbackListboxBehaviorProps = DismissBehaviorProps; + +export const FallbackListboxBehavior = (props: FallbackListboxBehaviorProps): null => { + useAutoOverlayStack(props); + useDismissBehavior(props); + return null; +}; + +FallbackListboxBehavior.displayName = 'FallbackListboxBehavior'; + +export type FallbackTooltipBehaviorProps = { + contentRef: ElementRef; + onDismiss: (event: Event) => void; + targetDocument: Document | undefined; + triggerRef: ElementRef; + visible: boolean; +}; + +export const FallbackTooltipBehavior = (props: FallbackTooltipBehaviorProps): null => { + const { contentRef, onDismiss, targetDocument, triggerRef, visible } = props; + + useOnClickOutside({ + contains: elementContains, + element: targetDocument, + callback: onDismiss, + refs: [triggerRef, contentRef], + disabled: !visible, + }); + + const dismiss = useEventCallback(onDismiss); + + React.useEffect(() => { + if (!visible || !targetDocument) { + return; + } + + tooltipEntries.get(targetDocument)?.dismiss(new Event('tooltipPeerDismiss')); + const entry = { dismiss }; + tooltipEntries.set(targetDocument, entry); + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === Escape && !event.defaultPrevented) { + event.preventDefault(); + dismiss(event); + } + }; + const onVisibilityChange = () => { + if (targetDocument.visibilityState === 'hidden') { + dismiss(new Event('visibilitychange')); + } + }; + + targetDocument.addEventListener('keydown', onKeyDown, true); + targetDocument.addEventListener('visibilitychange', onVisibilityChange); + + return () => { + if (tooltipEntries.get(targetDocument) === entry) { + tooltipEntries.delete(targetDocument); + } + targetDocument.removeEventListener('keydown', onKeyDown, true); + targetDocument.removeEventListener('visibilitychange', onVisibilityChange); + }; + }, [dismiss, targetDocument, visible]); + + return null; +}; + +FallbackTooltipBehavior.displayName = 'FallbackTooltipBehavior'; + +export type FallbackDialogBehaviorProps = { + contentRef: ElementRef; + lockScroll: boolean; + modal: boolean; + onDismiss: (event: Event) => void; + open: boolean; + targetDocument: Document | undefined; +}; + +export const FallbackDialogBehavior = (props: FallbackDialogBehaviorProps): null => { + useFocusBehavior({ + contentRef: props.contentRef, + open: props.open, + targetDocument: props.targetDocument, + trapFocus: props.modal, + }); + + React.useEffect(() => { + const targetDocument = props.targetDocument; + if (!props.open || !props.lockScroll || !targetDocument) { + return; + } + + lockDocumentScroll(targetDocument); + return () => unlockDocumentScroll(targetDocument); + }, [props.lockScroll, props.open, props.targetDocument]); + + useIsomorphicLayoutEffect(() => { + const content = props.contentRef.current; + if (!props.open || !content || !props.targetDocument) { + return; + } + + const stack = dialogStacks.get(props.targetDocument) ?? []; + if (!dialogStacks.has(props.targetDocument)) { + dialogStacks.set(props.targetDocument, stack); + } + stack.push(content); + + return () => { + const index = stack.indexOf(content); + if (index !== -1) { + stack.splice(index, 1); + } + }; + }, [props.contentRef, props.open, props.targetDocument]); + + const onKeyDown = useEventCallback((event: KeyboardEvent) => { + if ( + event.key === Escape && + !event.defaultPrevented && + props.targetDocument && + dialogStacks.get(props.targetDocument)?.at(-1) === props.contentRef.current + ) { + event.preventDefault(); + props.onDismiss(event); + } + }); + + React.useEffect(() => { + if (!props.open || !props.targetDocument) { + return; + } + + props.targetDocument.addEventListener('keydown', onKeyDown, true); + return () => props.targetDocument?.removeEventListener('keydown', onKeyDown, true); + }, [onKeyDown, props.open, props.targetDocument]); + + return null; +}; + +FallbackDialogBehavior.displayName = 'FallbackDialogBehavior'; + +/** + * Implementation loaded only for documents that cannot use the complete native + * overlay runtime. + */ +export const fallbackRuntime = { + FallbackDialogBehavior, + FallbackListboxBehavior, + FallbackMenuBehavior, + FallbackPopoverBehavior, + FallbackTooltipBehavior, + Portal, + positioning, +}; + +export type OverlayFallbackRuntime = typeof fallbackRuntime; diff --git a/packages/react-components/react-headless-components-preview/library/src/overlayRuntime/index.ts b/packages/react-components/react-headless-components-preview/library/src/overlayRuntime/index.ts new file mode 100644 index 00000000000000..f89d697be45523 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/overlayRuntime/index.ts @@ -0,0 +1,17 @@ +export { + getOverlayRuntimeSnapshot, + resetOverlayRuntimeForTests, + setOverlayFallbackLoaderForTests, + useOverlayRuntime, +} from './overlayRuntime'; +export type { OverlayRuntimeSnapshot } from './overlayRuntime'; + +export { + getOverlayRuntimeOverride, + setOverlayRuntimeOverrideForTests, + supportsNativeOverlayRuntime, +} from './nativeCapabilities'; +export type { OverlayRuntimeOverride } from './nativeCapabilities'; + +export { OverlaySurfaceHost } from './OverlaySurfaceHost'; +export type { OverlaySurfaceHostProps } from './OverlaySurfaceHost'; diff --git a/packages/react-components/react-headless-components-preview/library/src/overlayRuntime/nativeCapabilities.test.ts b/packages/react-components/react-headless-components-preview/library/src/overlayRuntime/nativeCapabilities.test.ts new file mode 100644 index 00000000000000..03c89233c378b4 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/overlayRuntime/nativeCapabilities.test.ts @@ -0,0 +1,78 @@ +import { + setOverlayRuntimeOverrideForTests, + supportsNativeOverlayRuntime, +} from './nativeCapabilities'; + +const createTargetDocument = (unsupportedFeature?: string): Document => { + class HTMLElementMock { + public popover = ''; + + public showPopover() { + return; + } + + public hidePopover() { + return; + } + + public setAttribute(name: string, value: string) { + if (name === 'popover') { + this.popover = value; + } + } + } + + class HTMLDialogElementMock extends HTMLElementMock { + public showModal() { + return; + } + } + + const css = { + supports: (property: string, value?: string) => { + const feature = value === undefined ? property : `${property}: ${value}`; + return feature !== unsupportedFeature; + }, + }; + + const targetWindow = { + CSS: css, + HTMLElement: HTMLElementMock, + HTMLDialogElement: HTMLDialogElementMock, + }; + + return { + defaultView: targetWindow, + createElement: () => new HTMLElementMock(), + } as unknown as Document; +}; + +describe('supportsNativeOverlayRuntime', () => { + afterEach(() => { + setOverlayRuntimeOverrideForTests(document, 'auto'); + }); + + it('supports an internal native override', () => { + setOverlayRuntimeOverrideForTests(document, 'native'); + + expect(supportsNativeOverlayRuntime(document)).toBe(true); + }); + + it('supports an internal fallback override', () => { + setOverlayRuntimeOverrideForTests(document, 'fallback'); + + expect(supportsNativeOverlayRuntime(document)).toBe(false); + }); + + it('accepts the complete native contract', () => { + expect(supportsNativeOverlayRuntime(createTargetDocument())).toBe(true); + }); + + it('rejects partial support', () => { + expect( + supportsNativeOverlayRuntime( + createTargetDocument('position-try-fallbacks: flip-block'), + ), + ).toBe(false); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/overlayRuntime/nativeCapabilities.ts b/packages/react-components/react-headless-components-preview/library/src/overlayRuntime/nativeCapabilities.ts new file mode 100644 index 00000000000000..4dc9806eb8eee3 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/overlayRuntime/nativeCapabilities.ts @@ -0,0 +1,66 @@ +export type OverlayRuntimeOverride = 'auto' | 'native' | 'fallback'; + +const OVERLAY_RUNTIME_OVERRIDE_KEY = '__FUI_HEADLESS_OVERLAY_RUNTIME_MODE__'; + +type OverlayRuntimeWindow = Window & { + [OVERLAY_RUNTIME_OVERRIDE_KEY]?: OverlayRuntimeOverride; +}; + +const supports = (css: typeof CSS, property: string, value?: string): boolean => + value === undefined ? css.supports(property) : css.supports(property, value); + +export function getOverlayRuntimeOverride(targetDocument: Document): OverlayRuntimeOverride { + return (targetDocument.defaultView as OverlayRuntimeWindow | null)?.[OVERLAY_RUNTIME_OVERRIDE_KEY] ?? 'auto'; +} + +export function setOverlayRuntimeOverrideForTests( + targetDocument: Document, + override: OverlayRuntimeOverride, +): void { + const targetWindow = targetDocument.defaultView as OverlayRuntimeWindow | null; + + if (targetWindow) { + targetWindow[OVERLAY_RUNTIME_OVERRIDE_KEY] = override; + } +} + +export function supportsNativeOverlayRuntime(targetDocument: Document): boolean { + const targetWindow = targetDocument.defaultView; + + if (!targetWindow) { + return false; + } + + const override = getOverlayRuntimeOverride(targetDocument); + if (override !== 'auto') { + return override === 'native'; + } + + const { CSS: css, HTMLElement: HTMLElementCtor, HTMLDialogElement: HTMLDialogElementCtor } = targetWindow; + if ( + !css || + typeof css.supports !== 'function' || + !HTMLElementCtor || + !HTMLDialogElementCtor + ) { + return false; + } + + const popoverPrototype = HTMLElementCtor.prototype; + const dialogPrototype = HTMLDialogElementCtor.prototype; + const hintProbe = targetDocument.createElement('div'); + hintProbe.setAttribute('popover', 'hint'); + + return ( + typeof popoverPrototype.showPopover === 'function' && + typeof popoverPrototype.hidePopover === 'function' && + typeof dialogPrototype.showModal === 'function' && + hintProbe.popover === 'hint' && + supports(css, 'selector(:popover-open)') && + supports(css, 'anchor-name', '--fui-overlay-anchor') && + supports(css, 'position-anchor', '--fui-overlay-anchor') && + supports(css, 'position-area', 'block-end') && + supports(css, 'position-try-fallbacks', 'flip-block') && + supports(css, 'width', 'anchor-size(width)') + ); +} diff --git a/packages/react-components/react-headless-components-preview/library/src/overlayRuntime/overlayRuntime.test.tsx b/packages/react-components/react-headless-components-preview/library/src/overlayRuntime/overlayRuntime.test.tsx new file mode 100644 index 00000000000000..c81c539854fff4 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/overlayRuntime/overlayRuntime.test.tsx @@ -0,0 +1,99 @@ +import * as React from 'react'; +import { act, render } from '@testing-library/react'; + +import { setOverlayRuntimeOverrideForTests } from './nativeCapabilities'; +import type { OverlayFallbackRuntime } from './fallbackRuntime'; +import { + getOverlayRuntimeSnapshot, + resetOverlayRuntimeForTests, + setOverlayFallbackLoaderForTests, + useOverlayRuntime, +} from './overlayRuntime'; + +const fallbackRuntime = { positioning: {} } as unknown as OverlayFallbackRuntime; + +describe('overlay runtime', () => { + beforeEach(() => { + resetOverlayRuntimeForTests(); + setOverlayRuntimeOverrideForTests(document, 'fallback'); + }); + + afterEach(() => { + resetOverlayRuntimeForTests(); + setOverlayRuntimeOverrideForTests(document, 'auto'); + }); + + it('loads one shared fallback runtime when the first overlay mounts', async () => { + let resolveRuntime: + | ((module: typeof import('./fallbackRuntime')) => void) + | undefined; + const loader = jest.fn( + () => + new Promise(resolve => { + resolveRuntime = resolve; + }), + ); + setOverlayFallbackLoaderForTests(loader); + + const snapshots: string[] = []; + const TestOverlay = () => { + const snapshot = useOverlayRuntime(document); + snapshots.push(snapshot.mode); + return null; + }; + + render( + <> + + + , + ); + + expect(loader).toHaveBeenCalledTimes(1); + expect(getOverlayRuntimeSnapshot(document).mode).toBe('fallback-loading'); + + await act(async () => { + resolveRuntime?.({ + fallbackRuntime, + } as typeof import('./fallbackRuntime')); + await Promise.resolve(); + }); + + expect(getOverlayRuntimeSnapshot(document)).toEqual({ mode: 'fallback-ready', runtime: fallbackRuntime }); + expect(snapshots).toContain('fallback-ready'); + }); + + it('does not load fallback code in native mode', () => { + setOverlayRuntimeOverrideForTests(document, 'native'); + const loader = jest.fn(); + setOverlayFallbackLoaderForTests(loader); + + const TestOverlay = () => { + useOverlayRuntime(document); + return null; + }; + + render(); + + expect(loader).not.toHaveBeenCalled(); + expect(getOverlayRuntimeSnapshot(document).mode).toBe('native'); + }); + + it('retains an explicit fallback loading error', async () => { + const error = new Error('chunk failed'); + setOverlayFallbackLoaderForTests(() => Promise.reject(error)); + + const TestOverlay = () => { + useOverlayRuntime(document); + return null; + }; + + render(); + + await act(async () => { + await Promise.resolve(); + }); + + expect(getOverlayRuntimeSnapshot(document)).toEqual({ mode: 'fallback-error', error }); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/overlayRuntime/overlayRuntime.ts b/packages/react-components/react-headless-components-preview/library/src/overlayRuntime/overlayRuntime.ts new file mode 100644 index 00000000000000..4065a87d948d3e --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/overlayRuntime/overlayRuntime.ts @@ -0,0 +1,120 @@ +'use client'; + +import * as React from 'react'; +import { useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; + +import type { OverlayFallbackRuntime } from './fallbackRuntime'; +import { supportsNativeOverlayRuntime } from './nativeCapabilities'; + +export type OverlayRuntimeSnapshot = + | { mode: 'ssr' } + | { mode: 'native' } + | { mode: 'fallback-idle' } + | { mode: 'fallback-loading' } + | { mode: 'fallback-ready'; runtime: OverlayFallbackRuntime } + | { mode: 'fallback-error'; error: unknown }; + +type OverlayRuntimeRecord = { + snapshot: OverlayRuntimeSnapshot; + listeners: Set<() => void>; +}; + +type OverlayFallbackModule = typeof import('./fallbackRuntime'); +type OverlayFallbackLoader = () => Promise; + +const defaultFallbackLoader: OverlayFallbackLoader = () => import('./fallbackRuntime'); + +let fallbackLoader = defaultFallbackLoader; +let fallbackRuntimePromise: Promise | undefined; +let runtimeRecords = new WeakMap(); + +const emit = (record: OverlayRuntimeRecord): void => { + for (const listener of record.listeners) { + listener(); + } +}; + +const getOrCreateRecord = (targetDocument: Document): OverlayRuntimeRecord => { + const existingRecord = runtimeRecords.get(targetDocument); + if (existingRecord) { + return existingRecord; + } + + const record: OverlayRuntimeRecord = { + snapshot: supportsNativeOverlayRuntime(targetDocument) ? { mode: 'native' } : { mode: 'fallback-idle' }, + listeners: new Set(), + }; + + runtimeRecords.set(targetDocument, record); + return record; +}; + +const loadFallbackRuntime = (): Promise => { + fallbackRuntimePromise ??= fallbackLoader().then(module => module.fallbackRuntime); + return fallbackRuntimePromise; +}; + +const startFallbackRuntime = (record: OverlayRuntimeRecord): void => { + if (record.snapshot.mode !== 'fallback-idle') { + return; + } + + record.snapshot = { mode: 'fallback-loading' }; + emit(record); + + loadFallbackRuntime().then( + runtime => { + record.snapshot = { mode: 'fallback-ready', runtime }; + emit(record); + }, + error => { + record.snapshot = { mode: 'fallback-error', error }; + emit(record); + }, + ); +}; + +export function getOverlayRuntimeSnapshot(targetDocument: Document | undefined): OverlayRuntimeSnapshot { + return targetDocument ? getOrCreateRecord(targetDocument).snapshot : { mode: 'ssr' }; +} + +export function useOverlayRuntime(targetDocument: Document | undefined): OverlayRuntimeSnapshot { + const [currentRecord, setCurrentRecord] = React.useState< + { targetDocument: Document; record: OverlayRuntimeRecord } | undefined + >(); + const [, forceUpdate] = React.useReducer(value => value + 1, 0); + const record = + currentRecord && currentRecord.targetDocument === targetDocument + ? currentRecord.record + : undefined; + + useIsomorphicLayoutEffect(() => { + if (!targetDocument) { + setCurrentRecord(undefined); + return; + } + + const nextRecord = getOrCreateRecord(targetDocument); + const listener = () => forceUpdate(); + nextRecord.listeners.add(listener); + setCurrentRecord({ targetDocument, record: nextRecord }); + startFallbackRuntime(nextRecord); + + return () => { + nextRecord.listeners.delete(listener); + }; + }, [targetDocument]); + + return record?.snapshot ?? { mode: 'ssr' }; +} + +export function resetOverlayRuntimeForTests(): void { + runtimeRecords = new WeakMap(); + fallbackRuntimePromise = undefined; + fallbackLoader = defaultFallbackLoader; +} + +export function setOverlayFallbackLoaderForTests(loader: OverlayFallbackLoader): void { + fallbackRuntimePromise = undefined; + fallbackLoader = loader; +} diff --git a/packages/react-components/react-headless-components-preview/library/src/overlayRuntime/types.ts b/packages/react-components/react-headless-components-preview/library/src/overlayRuntime/types.ts new file mode 100644 index 00000000000000..3d993dd103ed4e --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/overlayRuntime/types.ts @@ -0,0 +1,5 @@ +import type * as React from 'react'; + +export type WithFallbackBehavior = TState & { + fallbackBehavior?: React.ReactElement; +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/shared/browserSupportNotice.ts b/packages/react-components/react-headless-components-preview/stories/src/shared/browserSupportNotice.ts index 14e13b74f40a3d..44a9e402cfab4b 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/shared/browserSupportNotice.ts +++ b/packages/react-components/react-headless-components-preview/stories/src/shared/browserSupportNotice.ts @@ -45,6 +45,13 @@ export function getBrowserSupportNotice(component: string): string { } const featureList = keys.map(key => `${escapeHtml(FEATURE_LABELS[key])}`).join(', '); + const hasOverlayRuntime = keys.some(key => key === 'popover' || key === 'dialog' || key === 'anchor-positioning'); + const runtimeMessage = hasOverlayRuntime + ? ' Headless uses the native APIs when the complete feature set is available and otherwise loads a shared Fluent fallback automatically.' + : ''; + const focusGroupMessage = keys.includes('focusgroup') + ? ' Focus group still requires the documented consumer polyfill.' + : ''; return ( '' diff --git a/tools/workspace-plugin/src/executors/build/lib/swc.spec.ts b/tools/workspace-plugin/src/executors/build/lib/swc.spec.ts new file mode 100644 index 00000000000000..12de829ab3933b --- /dev/null +++ b/tools/workspace-plugin/src/executors/build/lib/swc.spec.ts @@ -0,0 +1,72 @@ +import { transformFile } from '@swc/core'; +import { readJsonFile } from '@nx/devkit'; +import { globSync } from 'fast-glob'; + +import { compileSwc } from './swc'; +import type { NormalizedOptions } from './shared'; + +jest.mock('@swc/core', () => ({ + transformFile: jest.fn(async () => ({ code: 'export {};' })), +})); +jest.mock('@nx/devkit', () => ({ + logger: { log: jest.fn(), verbose: jest.fn() }, + readJsonFile: jest.fn(() => ({ jsc: {} })), +})); +jest.mock('fast-glob', () => ({ + globSync: jest.fn(() => ['index.ts']), +})); +jest.mock('node:fs/promises', () => ({ + mkdir: jest.fn(async () => undefined), + writeFile: jest.fn(async () => undefined), + copyFile: jest.fn(async () => undefined), +})); + +const transformFileMock = transformFile as jest.MockedFunction; +const readJsonFileMock = readJsonFile as jest.MockedFunction; +const globSyncMock = globSync as jest.MockedFunction; + +const createOptions = (preserveDynamicImport: boolean): NormalizedOptions => + ({ + absoluteProjectRoot: '/repo/package', + absoluteSourceRoot: '/repo/package/src', + preserveDynamicImport, + }) as NormalizedOptions; + +describe('compileSwc', () => { + beforeEach(() => { + transformFileMock.mockClear(); + readJsonFileMock.mockClear(); + globSyncMock.mockClear(); + }); + + it('preserves dynamic imports only for opted-in CommonJS output', async () => { + await compileSwc( + { module: 'commonjs', outputPath: 'lib-commonjs' }, + createOptions(true), + ); + + expect(transformFileMock).toHaveBeenCalledWith( + '/repo/package/src/index.ts', + expect.objectContaining({ + module: expect.objectContaining({ + ignoreDynamic: true, + type: 'commonjs', + }), + }), + ); + }); + + it('keeps the existing CommonJS transform by default', async () => { + await compileSwc( + { module: 'commonjs', outputPath: 'lib-commonjs' }, + createOptions(false), + ); + + expect(transformFileMock).toHaveBeenCalledWith( + '/repo/package/src/index.ts', + expect.objectContaining({ + module: expect.not.objectContaining({ ignoreDynamic: true }), + }), + ); + }); +}); diff --git a/tools/workspace-plugin/src/executors/build/lib/swc.ts b/tools/workspace-plugin/src/executors/build/lib/swc.ts index ea4b85a04e51bf..64a084f85a5585 100644 --- a/tools/workspace-plugin/src/executors/build/lib/swc.ts +++ b/tools/workspace-plugin/src/executors/build/lib/swc.ts @@ -48,7 +48,11 @@ export async function compileSwc( } const result = await transformFile(srcFilePath, { - module: { type: module, resolveFully: Boolean(swcConfig.jsc?.baseUrl) }, + module: { + type: module, + resolveFully: Boolean(swcConfig.jsc?.baseUrl), + ...(module === 'commonjs' && normalizedOptions.preserveDynamicImport && { ignoreDynamic: true }), + }, // srcFilePath is absolute path so outputPath needs to be as well in order to properly emit relative path within .map (eg: `"sources":["../src/utils/createDarkTheme.ts"]`) outputPath: join(normalizedOptions.absoluteProjectRoot, outputPath), }); diff --git a/tools/workspace-plugin/src/executors/build/schema.d.ts b/tools/workspace-plugin/src/executors/build/schema.d.ts index 4058ba93c08da8..19e78f61fe0f03 100644 --- a/tools/workspace-plugin/src/executors/build/schema.d.ts +++ b/tools/workspace-plugin/src/executors/build/schema.d.ts @@ -44,6 +44,10 @@ export interface BuildExecutorSchema { * When enabled, source is processed with React Compiler as a separate Babel pass before Griffel AOT. */ reactCompiler?: boolean; + /** + * Keep dynamic import() expressions in CommonJS output so consuming bundlers can retain asynchronous chunk boundaries. + */ + preserveDynamicImport?: boolean; /** * TEMPORARY/INTERNAL: opts this build into ESM-first postprocessing (.cjs extension rewrite + .d.cts types) ahead of the package's own package.json declaring "type": "module". Exists so the infra can be exercised on a package before it actually flips to ESM. Will be removed once the real migration lands and every opted-in package sets "type": "module" itself. */ diff --git a/tools/workspace-plugin/src/executors/build/schema.json b/tools/workspace-plugin/src/executors/build/schema.json index 6c6d34b0899eb5..ee6eeb0d803a23 100644 --- a/tools/workspace-plugin/src/executors/build/schema.json +++ b/tools/workspace-plugin/src/executors/build/schema.json @@ -67,6 +67,11 @@ "description": "Enable babel-plugin-react-compiler processing. When enabled, source is processed with React Compiler as a separate Babel pass before Griffel AOT.", "default": false }, + "preserveDynamicImport": { + "type": "boolean", + "description": "Keep dynamic import() expressions in CommonJS output so consuming bundlers can retain asynchronous chunk boundaries.", + "default": false + }, "__esmFirst": { "type": "boolean", "description": "TEMPORARY/INTERNAL: opts this build into ESM-first postprocessing (.cjs extension rewrite + .d.cts types) ahead of the package's own package.json declaring \"type\": \"module\". Exists so the infra can be exercised on a package before it actually flips to ESM. Will be removed once the real migration lands and every opted-in package sets \"type\": \"module\" itself.", diff --git a/yarn.lock b/yarn.lock index ff372e8e73a616..8193cde677b1bd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4347,6 +4347,7 @@ __metadata: "@fluentui/react-overflow": "npm:^9.9.1" "@fluentui/react-persona": "npm:^9.7.6" "@fluentui/react-popover": "npm:^9.14.5" + "@fluentui/react-portal": "npm:^9.8.14" "@fluentui/react-positioning": "npm:^9.23.0" "@fluentui/react-progress": "npm:^9.5.3" "@fluentui/react-provider": "npm:^9.22.19" @@ -4372,8 +4373,8 @@ __metadata: "@fluentui/react-utilities": "npm:^9.26.5" "@fluentui/scripts-cypress": "npm:*" "@fluentui/verify-bundle-isolation": "npm:*" - "@oddbird/popover-polyfill": "npm:^0.6.1" "@swc/helpers": "npm:^0.5.1" + webpack: "npm:5.108.4" peerDependencies: "@types/react": ">=16.14.0 <20.0.0" "@types/react-dom": ">=16.9.0 <20.0.0" @@ -8577,13 +8578,6 @@ __metadata: languageName: node linkType: hard -"@oddbird/popover-polyfill@npm:^0.6.1": - version: 0.6.1 - resolution: "@oddbird/popover-polyfill@npm:0.6.1" - checksum: 10c0/2dca016336769f6429cc0b33f59589a94febccc92c893eaa95f07cf7bd8a6751f35511fb6768d81d1ab5a10ac35d6d2b2e8f6d43c35a75bf7f4734f259233a55 - languageName: node - linkType: hard - "@parcel/watcher-android-arm64@npm:2.5.1": version: 2.5.1 resolution: "@parcel/watcher-android-arm64@npm:2.5.1" From 5c90acb2bb988e5ad6fa173253c8186186720e59 Mon Sep 17 00:00:00 2001 From: Sean Monahan Date: Fri, 7 Aug 2026 23:52:47 +0000 Subject: [PATCH 03/11] fix(headless): stabilize adaptive overlay integration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../library/etc/positioning.api.md | 4 +-- .../library/etc/tag-picker.api.md | 4 +-- .../components/TagPicker/TagPicker.test.tsx | 2 +- .../src/overlayRuntime/fallbackRuntime.ts | 35 +++++-------------- .../src/overlayRuntime/overlayRuntime.ts | 13 +++---- .../library/etc/react-positioning.api.md | 24 +++++++------ 6 files changed, 33 insertions(+), 49 deletions(-) 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 07d315e12e20a9..539819caed269d 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 @@ -11,7 +11,6 @@ import type { PositioningProps as PositioningProps_2 } from '@fluentui/react-pos import { PositioningShorthand } from '@fluentui/react-positioning'; import { PositioningShorthandValue } from '@fluentui/react-positioning'; import type * as React_2 from 'react'; -import { resolvePositioningShorthand } from '@fluentui/react-positioning'; export { Alignment } @@ -50,7 +49,8 @@ export const POSITIONS: { readonly after: "after"; }; -export { resolvePositioningShorthand } +// @public (undocumented) +export function resolvePositioningShorthand(shorthand: PositioningShorthand | undefined | null): Readonly; // @public (undocumented) export function usePositioning(options: PositioningProps): PositioningReturn; 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 82c0d2516706ca..fdd807b4a86a6a 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 @@ -16,7 +16,6 @@ import type { OptionSlots as OptionSlots_2 } from '@fluentui/react-combobox'; import type { OptionState as OptionState_2 } from '@fluentui/react-combobox'; import { PositioningShorthand } from '@fluentui/react-positioning'; import type * as React_2 from 'react'; -import { renderTagPicker_unstable as renderTagPicker } from '@fluentui/react-tag-picker'; import { renderTagPickerButton_unstable as renderTagPickerButton } from '@fluentui/react-tag-picker'; import { renderTagPickerControl_unstable as renderTagPickerControl } from '@fluentui/react-tag-picker'; import { renderTagPickerGroup_unstable as renderTagPickerGroup } from '@fluentui/react-tag-picker'; @@ -50,7 +49,8 @@ import { TagPickerState } from '@fluentui/react-tag-picker'; import { useTagPickerContext_unstable } from '@fluentui/react-tag-picker'; import { useTagPickerContextValues } from '@fluentui/react-tag-picker'; -export { renderTagPicker } +// @public (undocumented) +export const renderTagPicker: (state: TagPickerState, contexts: TagPickerContextValues) => JSXElement; export { renderTagPickerButton } diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPicker.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPicker.test.tsx index 12cff439f018b4..a924b6375a590d 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPicker.test.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPicker.test.tsx @@ -121,7 +121,7 @@ describe('TagPicker', () => { expect(listbox).toHaveAttribute('data-placement', 'above-end'); expect(listbox).toHaveStyle({ positionArea: 'block-start span-inline-start' }); expect(listbox).toHaveStyle({ positionTryFallbacks: 'block-end' }); - expect(listbox).not.toHaveStyle({ width: 'anchor-size(width)' }); + expect(listbox.style.getPropertyValue('width')).toBe(''); }); it('does not apply a TagPicker-specific offset when positioning is omitted', () => { diff --git a/packages/react-components/react-headless-components-preview/library/src/overlayRuntime/fallbackRuntime.ts b/packages/react-components/react-headless-components-preview/library/src/overlayRuntime/fallbackRuntime.ts index fd9c6aec853087..24dbfe1b77f8cf 100644 --- a/packages/react-components/react-headless-components-preview/library/src/overlayRuntime/fallbackRuntime.ts +++ b/packages/react-components/react-headless-components-preview/library/src/overlayRuntime/fallbackRuntime.ts @@ -84,14 +84,7 @@ const useAutoOverlayStack = ( }; const useDismissBehavior = (props: DismissBehaviorProps): void => { - const { - closeOnScroll = false, - contentRef, - onDismiss, - open, - targetDocument, - triggerRef, - } = props; + const { closeOnScroll = false, contentRef, onDismiss, open, targetDocument, triggerRef } = props; useOnClickOutside({ contains: elementContains, @@ -115,7 +108,7 @@ const useDismissBehavior = (props: DismissBehaviorProps): void => { } const stack = getAutoOverlayStack(targetDocument); - if (stack.at(-1)?.content !== contentRef.current) { + if (stack[stack.length - 1]?.content !== contentRef.current) { return; } @@ -133,10 +126,7 @@ const useDismissBehavior = (props: DismissBehaviorProps): void => { }, [onKeyDown, open, targetDocument]); }; -const setAttributes = ( - element: HTMLElement, - attributes: Record, -): (() => void) => { +const setAttributes = (element: HTMLElement, attributes: Record): (() => void) => { const previousValues = new Map(); for (const [name, value] of Object.entries(attributes)) { @@ -186,15 +176,9 @@ const useFocusBehavior = (props: FocusBehaviorProps): void => { } previouslyFocusedRef.current = targetDocument?.activeElement as HTMLElement | null; - const restoreContentAttributes = setAttributes( - content, - modalAttributes as unknown as Record, - ); + const restoreContentAttributes = setAttributes(content, modalAttributes as unknown as Record); const restoreTriggerAttributes = triggerRef?.current - ? setAttributes( - triggerRef.current, - triggerAttributes as unknown as Record, - ) + ? setAttributes(triggerRef.current, triggerAttributes as unknown as Record) : undefined; const autofocusTarget = content.querySelector('[autofocus]'); @@ -387,12 +371,9 @@ export const FallbackDialogBehavior = (props: FallbackDialogBehaviorProps): null }, [props.contentRef, props.open, props.targetDocument]); const onKeyDown = useEventCallback((event: KeyboardEvent) => { - if ( - event.key === Escape && - !event.defaultPrevented && - props.targetDocument && - dialogStacks.get(props.targetDocument)?.at(-1) === props.contentRef.current - ) { + const stack = props.targetDocument ? dialogStacks.get(props.targetDocument) : undefined; + + if (event.key === Escape && !event.defaultPrevented && stack?.[stack.length - 1] === props.contentRef.current) { event.preventDefault(); props.onDismiss(event); } diff --git a/packages/react-components/react-headless-components-preview/library/src/overlayRuntime/overlayRuntime.ts b/packages/react-components/react-headless-components-preview/library/src/overlayRuntime/overlayRuntime.ts index 4065a87d948d3e..6e720b3ee28786 100644 --- a/packages/react-components/react-headless-components-preview/library/src/overlayRuntime/overlayRuntime.ts +++ b/packages/react-components/react-headless-components-preview/library/src/overlayRuntime/overlayRuntime.ts @@ -4,7 +4,7 @@ import * as React from 'react'; import { useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; import type { OverlayFallbackRuntime } from './fallbackRuntime'; -import { supportsNativeOverlayRuntime } from './nativeCapabilities'; +import { getOverlayRuntimeOverride, supportsNativeOverlayRuntime } from './nativeCapabilities'; export type OverlayRuntimeSnapshot = | { mode: 'ssr' } @@ -81,12 +81,13 @@ export function getOverlayRuntimeSnapshot(targetDocument: Document | undefined): export function useOverlayRuntime(targetDocument: Document | undefined): OverlayRuntimeSnapshot { const [currentRecord, setCurrentRecord] = React.useState< { targetDocument: Document; record: OverlayRuntimeRecord } | undefined - >(); + >(() => + targetDocument && getOverlayRuntimeOverride(targetDocument) === 'native' + ? { targetDocument, record: getOrCreateRecord(targetDocument) } + : undefined, + ); const [, forceUpdate] = React.useReducer(value => value + 1, 0); - const record = - currentRecord && currentRecord.targetDocument === targetDocument - ? currentRecord.record - : undefined; + const record = currentRecord && currentRecord.targetDocument === targetDocument ? currentRecord.record : undefined; useIsomorphicLayoutEffect(() => { if (!targetDocument) { 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 b62541d69fb44a..3b4ac5185f3dc3 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,26 +32,26 @@ export type CreateArrowStylesOptions = { borderColor?: GriffelStyle['borderBottomColor']; }; -// @public @deprecated -export function createSlideStyles(mainAxis: number): GriffelStyle; - // @public (undocumented) export function createPositioningManager_unstable(options: CreatePositioningManagerOptions_unstable): PositioningManager_unstable; -// @public -export function createVirtualElementFromClick(nativeEvent: MouseEvent): PositioningVirtualElement; - // @public (undocumented) export interface CreatePositioningManagerOptions_unstable extends Omit, Pick { - container: HTMLElement; - target: TargetElement; arrow?: HTMLElement | null; + container: HTMLElement; dir?: 'ltr' | 'rtl'; + target: TargetElement; targetDocument?: Document; unstable_disableShift?: boolean; unstable_flipFallbackStrategy?: PositioningFlipFallbackStrategy_unstable; } +// @public @deprecated +export function createSlideStyles(mainAxis: number): GriffelStyle; + +// @public +export function createVirtualElementFromClick(nativeEvent: MouseEvent): PositioningVirtualElement; + // @internal export function mergeArrowOffset(userOffset: Offset | undefined | null, arrowHeight: number): Offset; @@ -103,7 +103,7 @@ export type PositioningConfigurationFnOptions = Omit; -// @public (undocumented) +// @public export type PositioningFlipFallbackStrategy_unstable = 'bestFit' | 'initialPlacement'; // @public (undocumented) @@ -112,10 +112,12 @@ export type PositioningImperativeRef = { setTarget: (target: TargetElement | null) => void; }; -// @public (undocumented) +// @public export interface PositioningManager_unstable { - updatePosition: () => void; + // (undocumented) dispose: () => void; + // (undocumented) + updatePosition: () => void; } // @public From 8cd969e78afba034a02e78c2af27644e8a78450f Mon Sep 17 00:00:00 2001 From: Sean Monahan Date: Mon, 10 Aug 2026 20:20:35 +0000 Subject: [PATCH 04/11] refactor(react-positioning): reuse PositionManager type Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../library/etc/react-positioning.api.md | 18 +++++++++--------- .../library/src/createPositioningManager.ts | 6 +++--- .../react-positioning/library/src/index.ts | 2 +- .../library/src/types.test.ts | 6 +++--- .../react-positioning/library/src/types.ts | 9 ++------- 5 files changed, 18 insertions(+), 23 deletions(-) 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 3b4ac5185f3dc3..0d033f64c8c104 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 @@ -33,7 +33,7 @@ export type CreateArrowStylesOptions = { }; // @public (undocumented) -export function createPositioningManager_unstable(options: CreatePositioningManagerOptions_unstable): PositioningManager_unstable; +export function createPositioningManager_unstable(options: CreatePositioningManagerOptions_unstable): PositionManager; // @public (undocumented) export interface CreatePositioningManagerOptions_unstable extends Omit, Pick { @@ -112,14 +112,6 @@ export type PositioningImperativeRef = { setTarget: (target: TargetElement | null) => void; }; -// @public -export interface PositioningManager_unstable { - // (undocumented) - dispose: () => void; - // (undocumented) - updatePosition: () => void; -} - // @public export interface PositioningProps extends Pick { positioningRef?: React_2.Ref; @@ -155,6 +147,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/createPositioningManager.ts b/packages/react-components/react-positioning/library/src/createPositioningManager.ts index 24b014453167bd..283265b735c57e 100644 --- a/packages/react-components/react-positioning/library/src/createPositioningManager.ts +++ b/packages/react-components/react-positioning/library/src/createPositioningManager.ts @@ -8,17 +8,17 @@ import { resolvePositioningOptions } from './resolvePositioningOptions'; import type { CreatePositioningManagerOptions_unstable, OnPositioningEndEvent, - PositioningManager_unstable, + PositionManager, } from './types'; -const createNoopPositioningManager = (): PositioningManager_unstable => ({ +const createNoopPositioningManager = (): PositionManager => ({ updatePosition: () => undefined, dispose: () => undefined, }); export function createPositioningManager_unstable( options: CreatePositioningManagerOptions_unstable, -): PositioningManager_unstable { +): PositionManager { const { container, target, diff --git a/packages/react-components/react-positioning/library/src/index.ts b/packages/react-components/react-positioning/library/src/index.ts index 403a4783d002a0..b9a469e408ac28 100644 --- a/packages/react-components/react-positioning/library/src/index.ts +++ b/packages/react-components/react-positioning/library/src/index.ts @@ -27,6 +27,7 @@ export type { OffsetObject, OffsetShorthand, Position, + PositionManager, PositioningBoundary, PositioningImperativeRef, PositioningProps, @@ -38,5 +39,4 @@ export type { PositioningConfigurationFn, PositioningConfigurationFnOptions, PositioningFlipFallbackStrategy_unstable, - PositioningManager_unstable, } from './types'; diff --git a/packages/react-components/react-positioning/library/src/types.test.ts b/packages/react-components/react-positioning/library/src/types.test.ts index 282107b058e80e..3d8ba09c531c3f 100644 --- a/packages/react-components/react-positioning/library/src/types.test.ts +++ b/packages/react-components/react-positioning/library/src/types.test.ts @@ -2,7 +2,7 @@ import * as React from 'react'; import type { OnPositioningEndEvent } from './types'; import type { CreatePositioningManagerOptions_unstable, - PositioningManager_unstable, + PositionManager, PositioningProps, } from './types'; @@ -71,8 +71,8 @@ describe('PositioningProps', () => { expect(options).toBeTruthy; }); - it('supports the unstable imperative manager contract', () => { - const manager: PositioningManager_unstable = { + it('supports the imperative manager contract', () => { + const manager: PositionManager = { updatePosition: () => undefined, dispose: () => undefined, }; diff --git a/packages/react-components/react-positioning/library/src/types.ts b/packages/react-components/react-positioning/library/src/types.ts index d48a640edd44f2..7eaba5eb9c4268 100644 --- a/packages/react-components/react-positioning/library/src/types.ts +++ b/packages/react-components/react-positioning/library/src/types.ts @@ -71,18 +71,13 @@ export type TargetElement = HTMLElement | PositioningVirtualElement; export type PositioningFlipFallbackStrategy_unstable = 'bestFit' | 'initialPlacement'; /** - * Public imperative positioning manager contract. + * Imperative positioning manager contract. */ -export interface PositioningManager_unstable { +export interface PositionManager { updatePosition: () => void; dispose: () => void; } -/** - * @internal - */ -export interface PositionManager extends PositioningManager_unstable {} - export interface UsePositioningReturn { // React refs are supposed to be contravariant // (allows a more general type to be passed rather than a more specific one) From e11f13b9967a0485db039bc32325ac47ba8fb02c Mon Sep 17 00:00:00 2001 From: Sean Monahan Date: Mon, 10 Aug 2026 20:29:33 +0000 Subject: [PATCH 05/11] refactor(headless): share document scroll lock Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Dialog/DialogSurface/useDialogSurface.ts | 2 +- .../src/overlayRuntime/fallbackRuntime.ts | 2 +- .../src/utils/documentScrollLock.test.ts | 55 +++++++++++++++++++ .../scroll.ts => utils/documentScrollLock.ts} | 13 ++--- 4 files changed, 63 insertions(+), 9 deletions(-) create mode 100644 packages/react-components/react-headless-components-preview/library/src/utils/documentScrollLock.test.ts rename packages/react-components/react-headless-components-preview/library/src/{components/Dialog/utils/scroll.ts => utils/documentScrollLock.ts} (64%) diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/DialogSurface/useDialogSurface.ts b/packages/react-components/react-headless-components-preview/library/src/components/Dialog/DialogSurface/useDialogSurface.ts index c4a7024d7c840e..6e7c1f3270d00f 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/DialogSurface/useDialogSurface.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Dialog/DialogSurface/useDialogSurface.ts @@ -12,7 +12,7 @@ import { import { useDialogContext } from '../dialogContext'; import { stringifyDataAttribute } from '../../../utils'; import { useOverlayRuntime } from '../../../overlayRuntime'; -import { lockDocumentScroll, unlockDocumentScroll } from '../utils/scroll'; +import { lockDocumentScroll, unlockDocumentScroll } from '../../../utils/documentScrollLock'; import type { DialogSurfaceProps, DialogSurfaceState } from './DialogSurface.types'; type DialogSurfaceStateInternal = DialogSurfaceState & { diff --git a/packages/react-components/react-headless-components-preview/library/src/overlayRuntime/fallbackRuntime.ts b/packages/react-components/react-headless-components-preview/library/src/overlayRuntime/fallbackRuntime.ts index 24dbfe1b77f8cf..104b3d4e0de92c 100644 --- a/packages/react-components/react-headless-components-preview/library/src/overlayRuntime/fallbackRuntime.ts +++ b/packages/react-components/react-headless-components-preview/library/src/overlayRuntime/fallbackRuntime.ts @@ -10,7 +10,7 @@ import { useOnScrollOutside, } from '@fluentui/react-utilities'; import { useActivateModal, useFocusFinders, useModalAttributes } from '@fluentui/react-tabster'; -import { lockDocumentScroll, unlockDocumentScroll } from '../components/Dialog/utils/scroll'; +import { lockDocumentScroll, unlockDocumentScroll } from '../utils/documentScrollLock'; type ElementRef = React.RefObject; diff --git a/packages/react-components/react-headless-components-preview/library/src/utils/documentScrollLock.test.ts b/packages/react-components/react-headless-components-preview/library/src/utils/documentScrollLock.test.ts new file mode 100644 index 00000000000000..38987db01355bf --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/utils/documentScrollLock.test.ts @@ -0,0 +1,55 @@ +import { lockDocumentScroll, unlockDocumentScroll } from './documentScrollLock'; + +const createDocument = (): Document => document.implementation.createHTMLDocument(); + +describe('documentScrollLock', () => { + it('restores the previous inline overflow value', () => { + const targetDocument = createDocument(); + targetDocument.body.style.overflow = 'auto'; + + lockDocumentScroll(targetDocument); + expect(targetDocument.body.style.overflow).toBe('visible clip'); + + unlockDocumentScroll(targetDocument); + expect(targetDocument.body.style.overflow).toBe('auto'); + }); + + it('keeps the document locked until all nested locks are released', () => { + const targetDocument = createDocument(); + + lockDocumentScroll(targetDocument); + lockDocumentScroll(targetDocument); + + unlockDocumentScroll(targetDocument); + expect(targetDocument.body.style.overflow).toBe('visible clip'); + + unlockDocumentScroll(targetDocument); + expect(targetDocument.body.style.overflow).toBe(''); + }); + + it('tracks locks independently for each document', () => { + const firstDocument = createDocument(); + const secondDocument = createDocument(); + firstDocument.body.style.overflow = 'scroll'; + secondDocument.body.style.overflow = 'auto'; + + lockDocumentScroll(firstDocument); + lockDocumentScroll(secondDocument); + unlockDocumentScroll(firstDocument); + + expect(firstDocument.body.style.overflow).toBe('scroll'); + expect(secondDocument.body.style.overflow).toBe('visible clip'); + + unlockDocumentScroll(secondDocument); + expect(secondDocument.body.style.overflow).toBe('auto'); + }); + + it('ignores an unmatched unlock', () => { + const targetDocument = createDocument(); + targetDocument.body.style.overflow = 'scroll'; + + unlockDocumentScroll(targetDocument); + + expect(targetDocument.body.style.overflow).toBe('scroll'); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/utils/scroll.ts b/packages/react-components/react-headless-components-preview/library/src/utils/documentScrollLock.ts similarity index 64% rename from packages/react-components/react-headless-components-preview/library/src/components/Dialog/utils/scroll.ts rename to packages/react-components/react-headless-components-preview/library/src/utils/documentScrollLock.ts index c2a583630357bc..a62c32ee40dcf7 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/utils/scroll.ts +++ b/packages/react-components/react-headless-components-preview/library/src/utils/documentScrollLock.ts @@ -6,11 +6,11 @@ type ScrollLockState = { const scrollLockStateByDocument = new WeakMap(); /** - * Prevents background scrolling while a modal/alert dialog is open by applying - * `overflow: hidden` to ``. The `` element is intentionally left - * untouched so host-application styles on the document element are preserved. + * Prevents background scrolling while a modal overlay is open. * - * Nested modal dialogs share a single lock via a reference count. + * Locks are reference-counted per document so nested overlays share one lock, + * and the body's original inline overflow value is restored after the final + * unlock. */ export function lockDocumentScroll(targetDocument: Document): void { const previous = scrollLockStateByDocument.get(targetDocument); @@ -28,9 +28,8 @@ export function lockDocumentScroll(targetDocument: Document): void { } /** - * Restores the document's scroll behavior by reverting the `overflow` style - * on the `` element to its previous value. This function is typically - * called when a modal/alert dialog is closed. + * Releases one document scroll lock and restores the original body overflow + * value after the final nested lock is released. */ export function unlockDocumentScroll(targetDocument: Document): void { const state = scrollLockStateByDocument.get(targetDocument); From d4aec4f9673c8d97e83ef765b5b4e4da6f53364b Mon Sep 17 00:00:00 2001 From: Sean Monahan Date: Mon, 10 Aug 2026 20:49:21 +0000 Subject: [PATCH 06/11] feat(ssr-tests-v9): add overlay runtime playground Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/ssr-tests-v9/README.md | 28 + apps/ssr-tests-v9/package.json | 1 + .../ManualVerification.stories.tsx | 726 ++++++++++++++++++ .../stories/OverlayRuntime/index.stories.tsx | 5 + yarn.lock | 1 + 5 files changed, 761 insertions(+) create mode 100644 apps/ssr-tests-v9/src/stories/OverlayRuntime/ManualVerification.stories.tsx create mode 100644 apps/ssr-tests-v9/src/stories/OverlayRuntime/index.stories.tsx diff --git a/apps/ssr-tests-v9/README.md b/apps/ssr-tests-v9/README.md index 7b6226427a0cbc..81a3ae14deaeee 100644 --- a/apps/ssr-tests-v9/README.md +++ b/apps/ssr-tests-v9/README.md @@ -10,3 +10,31 @@ not want on the public docsite. The project does not actually need to host a storybook, but the storybook setup is still applied for consistency with other projects. + +## Overlay runtime manual verification + +The **Overlay Runtime / Manual Verification** story exercises the Headless +native Popover/Anchor implementation and its lazy Portal/Floating UI fallback. +It includes stable `data-testid` hooks for future Playwright coverage, but no +Playwright tests are added yet. + +Start the private Storybook from the repository root: + +```bash +yarn nx storybook ssr-tests-v9 +``` + +Open: + +```text +http://localhost:6006/?path=/story/overlay-runtime--manual-verification +``` + +Use the story's reload controls to run the checklist in: + +- auto-detected mode; +- forced native mode; +- forced fallback mode. + +The selected mode is stored in the `overlayRuntime` query parameter, and the +story displays the runtime actually applied to the rendered overlay surfaces. diff --git a/apps/ssr-tests-v9/package.json b/apps/ssr-tests-v9/package.json index 8d12bcd7285f7a..219913b030e223 100644 --- a/apps/ssr-tests-v9/package.json +++ b/apps/ssr-tests-v9/package.json @@ -19,6 +19,7 @@ }, "dependencies": { "@fluentui/react-components": "*", + "@fluentui/react-headless-components-preview": "*", "@fluentui/react-utilities": "*" } } diff --git a/apps/ssr-tests-v9/src/stories/OverlayRuntime/ManualVerification.stories.tsx b/apps/ssr-tests-v9/src/stories/OverlayRuntime/ManualVerification.stories.tsx new file mode 100644 index 00000000000000..fad89beb412b4f --- /dev/null +++ b/apps/ssr-tests-v9/src/stories/OverlayRuntime/ManualVerification.stories.tsx @@ -0,0 +1,726 @@ +import * as React from 'react'; + +import { Button, makeStyles, tokens } from '@fluentui/react-components'; +import { + Dialog, + DialogActions, + DialogBody, + DialogSurface, + DialogTitle, + DialogTrigger, +} from '@fluentui/react-headless-components-preview/dialog'; +import { Dropdown, Option } from '@fluentui/react-headless-components-preview/dropdown'; +import { Menu, MenuItem, MenuList, MenuPopover, MenuTrigger } from '@fluentui/react-headless-components-preview/menu'; +import { Popover, PopoverSurface, PopoverTrigger } from '@fluentui/react-headless-components-preview/popover'; +import { Toast, Toaster, ToastTitle, useToastController } from '@fluentui/react-headless-components-preview/toast'; +import { Tooltip } from '@fluentui/react-headless-components-preview/tooltip'; + +type RuntimeMode = 'auto' | 'native' | 'fallback'; +type RuntimeWindow = Window & { + __FUI_HEADLESS_OVERLAY_RUNTIME_MODE__?: RuntimeMode; +}; + +const RUNTIME_QUERY_PARAM = 'overlayRuntime'; + +const useStyles = makeStyles({ + page: { + display: 'grid', + gap: tokens.spacingVerticalXXL, + minHeight: '100vh', + padding: tokens.spacingHorizontalXXL, + color: tokens.colorNeutralForeground1, + backgroundColor: tokens.colorNeutralBackground1, + }, + header: { + display: 'grid', + gap: tokens.spacingVerticalM, + maxWidth: '960px', + }, + title: { + margin: 0, + fontFamily: tokens.fontFamilyBase, + fontSize: tokens.fontSizeHero800, + lineHeight: tokens.lineHeightHero800, + }, + subtitle: { + margin: 0, + color: tokens.colorNeutralForeground2, + fontSize: tokens.fontSizeBase400, + lineHeight: tokens.lineHeightBase400, + }, + statusPanel: { + display: 'flex', + flexWrap: 'wrap', + alignItems: 'center', + gap: tokens.spacingHorizontalM, + padding: tokens.spacingHorizontalM, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, + borderRadius: tokens.borderRadiusLarge, + backgroundColor: tokens.colorNeutralBackground2, + }, + statusLabel: { + fontWeight: tokens.fontWeightSemibold, + }, + modeControls: { + display: 'flex', + flexWrap: 'wrap', + gap: tokens.spacingHorizontalS, + }, + grid: { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', + gap: tokens.spacingHorizontalL, + alignItems: 'start', + }, + card: { + display: 'grid', + gap: tokens.spacingVerticalM, + minWidth: 0, + padding: tokens.spacingHorizontalL, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, + borderRadius: tokens.borderRadiusLarge, + backgroundColor: tokens.colorNeutralBackground2, + boxShadow: tokens.shadow4, + }, + wideCard: { + gridColumn: '1 / -1', + }, + cardTitle: { + margin: 0, + fontSize: tokens.fontSizeBase500, + lineHeight: tokens.lineHeightBase500, + }, + cardCopy: { + margin: 0, + color: tokens.colorNeutralForeground2, + lineHeight: tokens.lineHeightBase400, + }, + row: { + display: 'flex', + flexWrap: 'wrap', + alignItems: 'center', + gap: tokens.spacingHorizontalM, + }, + column: { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: tokens.spacingVerticalS, + }, + surface: { + minWidth: '240px', + maxWidth: '340px', + margin: 0, + padding: tokens.spacingHorizontalL, + color: tokens.colorNeutralForeground1, + backgroundColor: tokens.colorNeutralBackground1, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, + borderRadius: tokens.borderRadiusLarge, + boxShadow: tokens.shadow16, + '& [data-arrow]': { + position: 'absolute', + width: '12px', + height: '12px', + backgroundColor: tokens.colorNeutralBackground1, + transform: 'rotate(45deg)', + }, + '&[data-placement^="above"] [data-arrow]': { + bottom: '-6px', + }, + '&[data-placement^="below"] [data-arrow]': { + top: '-6px', + }, + '&[data-placement^="before"] [data-arrow]': { + right: '-6px', + }, + '&[data-placement^="after"] [data-arrow]': { + left: '-6px', + }, + '&[data-placement="above"] [data-arrow], &[data-placement="below"] [data-arrow]': { + insetInline: 0, + marginInline: 'auto', + }, + '&[data-placement="before"] [data-arrow], &[data-placement="after"] [data-arrow]': { + insetBlock: 0, + marginBlock: 'auto', + }, + }, + nestedSurface: { + minWidth: '280px', + }, + menuSurface: { + display: 'flex', + flexDirection: 'column', + minWidth: '180px', + padding: tokens.spacingVerticalXS, + }, + menuList: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXXS, + outlineStyle: 'none', + }, + menuItem: { + padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`, + color: tokens.colorNeutralForeground1, + backgroundColor: 'transparent', + borderRadius: tokens.borderRadiusMedium, + textAlign: 'start', + cursor: 'pointer', + '&:hover, &:focus-visible': { + backgroundColor: tokens.colorNeutralBackground1Hover, + outline: `${tokens.strokeWidthThick} solid ${tokens.colorBrandStroke1}`, + }, + }, + tooltip: { + padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`, + color: tokens.colorNeutralForegroundInverted, + backgroundColor: tokens.colorNeutralBackgroundStatic, + borderRadius: tokens.borderRadiusMedium, + boxShadow: tokens.shadow8, + }, + dropdownRoot: { + position: 'relative', + display: 'flex', + width: '280px', + }, + dropdownButton: { + display: 'flex', + justifyContent: 'space-between', + width: '100%', + padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`, + color: tokens.colorNeutralForeground1, + backgroundColor: tokens.colorNeutralBackground1, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, + borderRadius: tokens.borderRadiusMedium, + }, + listbox: { + display: 'flex', + flexDirection: 'column', + minWidth: '280px', + maxHeight: '240px', + overflowY: 'auto', + padding: tokens.spacingVerticalXS, + color: tokens.colorNeutralForeground1, + backgroundColor: tokens.colorNeutralBackground1, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, + borderRadius: tokens.borderRadiusMedium, + boxShadow: tokens.shadow16, + }, + option: { + padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`, + borderRadius: tokens.borderRadiusMedium, + '&:hover, &[data-activedescendant-focusvisible]': { + backgroundColor: tokens.colorNeutralBackground1Hover, + }, + '&[data-selected]': { + color: tokens.colorBrandForeground1, + backgroundColor: tokens.colorBrandBackground2, + }, + }, + dialogSurface: { + position: 'fixed', + inset: 0, + width: 'min(92vw, 520px)', + maxHeight: '80vh', + margin: 'auto', + padding: tokens.spacingHorizontalXL, + color: tokens.colorNeutralForeground1, + backgroundColor: tokens.colorNeutralBackground1, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, + borderRadius: tokens.borderRadiusXLarge, + boxShadow: tokens.shadow64, + '&::backdrop': { + backgroundColor: tokens.colorBackgroundOverlay, + }, + }, + dialogBody: { + display: 'grid', + gap: tokens.spacingVerticalM, + }, + dialogTitle: { + margin: 0, + fontSize: tokens.fontSizeBase600, + lineHeight: tokens.lineHeightBase600, + }, + dialogActions: { + display: 'flex', + justifyContent: 'flex-end', + gap: tokens.spacingHorizontalS, + marginTop: tokens.spacingVerticalL, + }, + edgeCanvas: { + position: 'relative', + height: '280px', + overflow: 'hidden', + border: `${tokens.strokeWidthThin} dashed ${tokens.colorNeutralStroke2}`, + borderRadius: tokens.borderRadiusLarge, + backgroundColor: tokens.colorNeutralBackground3, + }, + edgeTrigger: { + position: 'absolute', + insetInlineEnd: tokens.spacingHorizontalM, + insetBlockEnd: tokens.spacingVerticalM, + }, + placement: { + display: 'inline-flex', + padding: `${tokens.spacingVerticalXXS} ${tokens.spacingHorizontalXS}`, + fontFamily: tokens.fontFamilyMonospace, + fontSize: tokens.fontSizeBase200, + color: tokens.colorBrandForeground1, + backgroundColor: tokens.colorBrandBackground2, + borderRadius: tokens.borderRadiusSmall, + }, + checklist: { + display: 'grid', + gap: tokens.spacingVerticalXS, + margin: 0, + paddingInlineStart: tokens.spacingHorizontalXL, + color: tokens.colorNeutralForeground2, + }, + probeTrigger: { + position: 'absolute', + width: '1px', + height: '1px', + overflow: 'hidden', + clip: 'rect(0 0 0 0)', + clipPath: 'inset(50%)', + whiteSpace: 'nowrap', + }, + toast: { + minWidth: '280px', + padding: tokens.spacingHorizontalM, + color: tokens.colorNeutralForeground1, + backgroundColor: tokens.colorNeutralBackground1, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, + borderRadius: tokens.borderRadiusLarge, + boxShadow: tokens.shadow16, + }, + toaster: { + position: 'fixed', + insetBlockStart: tokens.spacingVerticalL, + insetInlineEnd: tokens.spacingHorizontalL, + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalS, + }, +}); + +const getRuntimeMode = (): RuntimeMode => { + const requested = new URLSearchParams(window.location.search).get(RUNTIME_QUERY_PARAM); + return requested === 'native' || requested === 'fallback' ? requested : 'auto'; +}; + +const reloadWithRuntime = (mode: RuntimeMode): void => { + const url = new URL(window.location.href); + if (mode === 'auto') { + url.searchParams.delete(RUNTIME_QUERY_PARAM); + } else { + url.searchParams.set(RUNTIME_QUERY_PARAM, mode); + } + window.location.assign(url.toString()); +}; + +const usePlacementReadout = (): [React.RefCallback, string] => { + const [element, setElement] = React.useState(null); + const [placement, setPlacement] = React.useState('not mounted'); + + React.useEffect(() => { + if (!element) { + setPlacement('not mounted'); + return; + } + + const update = () => setPlacement(element.getAttribute('data-placement') ?? 'pending'); + update(); + + const observer = new MutationObserver(update); + observer.observe(element, { + attributes: true, + attributeFilter: ['data-placement'], + }); + + return () => observer.disconnect(); + }, [element]); + + return [setElement, placement]; +}; + +const RuntimeSentinel = (props: { onRuntimeChange: (runtime: string) => void }): React.ReactElement => { + const { onRuntimeChange } = props; + const styles = useStyles(); + const [element, setElement] = React.useState(null); + + React.useEffect(() => { + if (!element) { + return; + } + + const update = () => onRuntimeChange(element.getAttribute('data-overlay-runtime') ?? 'pending'); + update(); + + const observer = new MutationObserver(update); + observer.observe(element, { + attributes: true, + attributeFilter: ['data-overlay-runtime'], + }); + + return () => observer.disconnect(); + }, [element, onRuntimeChange]); + + return ( + + Runtime probe +
+ } + relationship="description" + > + + + ); +}; + +const RuntimeControls = (props: { requestedMode: RuntimeMode; resolvedMode: string }): React.ReactElement => { + const { requestedMode, resolvedMode } = props; + const styles = useStyles(); + + return ( +
+ + Requested:{' '} + {requestedMode} + + + Resolved:{' '} + {resolvedMode} + +
+ {(['auto', 'native', 'fallback'] as const).map(mode => ( + + ))} +
+
+ ); +}; + +const BasicPopoverExample = (): React.ReactElement => { + const styles = useStyles(); + const [open, setOpen] = React.useState(false); + const [surfaceRef, placement] = usePlacementReadout(); + + return ( +
+

Basic Popover

+

+ Verify click, outside click, Escape, arrow placement, focus restoration, and controlled close. +

+
+ setOpen(data.open)} positioning="below-start" withArrow> + + + + +
+ Adaptive popover + + Placement:{' '} + + {placement} + + + +
+
+
+ {open ? 'open' : 'closed'} +
+
+ ); +}; + +const EdgePlacementExample = (): React.ReactElement => { + const styles = useStyles(); + const [surfaceRef, placement] = usePlacementReadout(); + + return ( +
+

Collision and fallback placement

+

+ The requested placement is below-end at the canvas corner. It should resolve to a fitting fallback and keep the + surface inside the viewport. +

+
+ + + + + +
+ Collision test + + Resolved placement:{' '} + + {placement} + + +
+
+
+
+
+ ); +}; + +const NestedPopoverExample = (): React.ReactElement => { + const styles = useStyles(); + + return ( +
+

Nested Popovers

+

+ Open both surfaces, then press Escape. The inner surface should close first while the outer remains open. +

+ + + + + +
+ Outer popover + + + + + + Inner popover + + +
+
+
+
+ ); +}; + +const MenuAndTooltipExample = (): React.ReactElement => { + const styles = useStyles(); + + return ( +
+

Menu and Tooltip

+

Verify menu focus/outside dismissal and tooltip hover, focus, and Escape.

+
+ + + + + + + New file + Open file + Save + + + + + Adaptive tooltip +
+ } + relationship="description" + showDelay={0} + > + + +
+ + ); +}; + +const DropdownExample = (): React.ReactElement => { + const styles = useStyles(); + + return ( +
+

Dropdown

+

+ Verify listbox portal placement, keyboard navigation, selection, and outside dismissal. +

+ + + + + +
+ ); +}; + +const DialogExample = (): React.ReactElement => { + const styles = useStyles(); + + return ( +
+

Modal Dialog

+

+ Verify backdrop, initial focus, Tab trapping, Escape, close action, and focus restoration. +

+ + + + + + + Adaptive modal dialog + Tab through both actions. Focus should remain in the dialog. + + + + + + + + + +
+ ); +}; + +const ToastExample = (): React.ReactElement => { + const styles = useStyles(); + const toasterId = 'overlay-runtime-manual-toaster'; + const { dispatchToast } = useToastController(toasterId); + + const notify = () => { + dispatchToast( + + Adaptive overlay toast + , + { + intent: 'success', + position: 'top-end', + timeout: 10_000, + toastId: 'overlay-runtime-toast', + }, + ); + }; + + return ( +
+

Toaster

+

+ Verify the toast position container renders in the top layer or fallback Portal. +

+ + +
+ ); +}; + +const ManualChecklist = (): React.ReactElement => { + const styles = useStyles(); + + return ( +
+

Manual verification checklist

+
    +
  1. Run the checklist once in forced native and once in forced fallback.
  2. +
  3. Confirm the resolved runtime matches the requested mode.
  4. +
  5. Confirm outside click and Escape dismiss only the expected overlay.
  6. +
  7. Confirm focus returns to each trigger after dismissal.
  8. +
  9. Confirm the edge popover flips and its placement readout updates.
  10. +
  11. Confirm nested Escape closes the inner popover before the outer.
  12. +
  13. Confirm the modal backdrop, Tab behavior, and toast placement.
  14. +
+
+ ); +}; + +const OverlayPlayground = (props: { requestedMode: RuntimeMode }): React.ReactElement => { + const { requestedMode } = props; + const styles = useStyles(); + const [resolvedMode, setResolvedMode] = React.useState('pending'); + + return ( +
+ + +
+

Headless overlay runtime verification

+

+ This private story exercises the native Popover/Anchor runtime and the lazy Portal/Floating UI fallback + through the same component APIs. +

+ +
+
+ + + + + + + + +
+
+ ); +}; + +const RuntimeGate = (): React.ReactElement => { + const [requestedMode, setRequestedMode] = React.useState(null); + + React.useEffect(() => { + const mode = getRuntimeMode(); + (window as RuntimeWindow).__FUI_HEADLESS_OVERLAY_RUNTIME_MODE__ = mode; + setRequestedMode(mode); + }, []); + + if (!requestedMode) { + return
Initializing overlay runtime...
; + } + + return ; +}; + +export const ManualVerification = (): React.ReactElement => ; diff --git a/apps/ssr-tests-v9/src/stories/OverlayRuntime/index.stories.tsx b/apps/ssr-tests-v9/src/stories/OverlayRuntime/index.stories.tsx new file mode 100644 index 00000000000000..da2e179489d162 --- /dev/null +++ b/apps/ssr-tests-v9/src/stories/OverlayRuntime/index.stories.tsx @@ -0,0 +1,5 @@ +export { ManualVerification } from './ManualVerification.stories'; + +export default { + title: 'Overlay Runtime', +}; diff --git a/yarn.lock b/yarn.lock index 8193cde677b1bd..8d1753444f6f1c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6281,6 +6281,7 @@ __metadata: resolution: "@fluentui/ssr-tests-v9@workspace:apps/ssr-tests-v9" dependencies: "@fluentui/react-components": "npm:*" + "@fluentui/react-headless-components-preview": "npm:*" "@fluentui/react-utilities": "npm:*" languageName: unknown linkType: soft From 3bf7a9523885842d5e4f9f5560b85a939ac23be2 Mon Sep 17 00:00:00 2001 From: Sean Monahan Date: Mon, 10 Aug 2026 20:58:03 +0000 Subject: [PATCH 07/11] refactor(tests): isolate Headless popover app Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/ssr-tests-v9/README.md | 28 - apps/ssr-tests-v9/package.json | 1 - .../ManualVerification.stories.tsx | 726 ------------------ .../stories/OverlayRuntime/index.stories.tsx | 5 - yarn.lock | 10 +- 5 files changed, 9 insertions(+), 761 deletions(-) delete mode 100644 apps/ssr-tests-v9/src/stories/OverlayRuntime/ManualVerification.stories.tsx delete mode 100644 apps/ssr-tests-v9/src/stories/OverlayRuntime/index.stories.tsx diff --git a/apps/ssr-tests-v9/README.md b/apps/ssr-tests-v9/README.md index 81a3ae14deaeee..7b6226427a0cbc 100644 --- a/apps/ssr-tests-v9/README.md +++ b/apps/ssr-tests-v9/README.md @@ -10,31 +10,3 @@ not want on the public docsite. The project does not actually need to host a storybook, but the storybook setup is still applied for consistency with other projects. - -## Overlay runtime manual verification - -The **Overlay Runtime / Manual Verification** story exercises the Headless -native Popover/Anchor implementation and its lazy Portal/Floating UI fallback. -It includes stable `data-testid` hooks for future Playwright coverage, but no -Playwright tests are added yet. - -Start the private Storybook from the repository root: - -```bash -yarn nx storybook ssr-tests-v9 -``` - -Open: - -```text -http://localhost:6006/?path=/story/overlay-runtime--manual-verification -``` - -Use the story's reload controls to run the checklist in: - -- auto-detected mode; -- forced native mode; -- forced fallback mode. - -The selected mode is stored in the `overlayRuntime` query parameter, and the -story displays the runtime actually applied to the rendered overlay surfaces. diff --git a/apps/ssr-tests-v9/package.json b/apps/ssr-tests-v9/package.json index 219913b030e223..8d12bcd7285f7a 100644 --- a/apps/ssr-tests-v9/package.json +++ b/apps/ssr-tests-v9/package.json @@ -19,7 +19,6 @@ }, "dependencies": { "@fluentui/react-components": "*", - "@fluentui/react-headless-components-preview": "*", "@fluentui/react-utilities": "*" } } diff --git a/apps/ssr-tests-v9/src/stories/OverlayRuntime/ManualVerification.stories.tsx b/apps/ssr-tests-v9/src/stories/OverlayRuntime/ManualVerification.stories.tsx deleted file mode 100644 index fad89beb412b4f..00000000000000 --- a/apps/ssr-tests-v9/src/stories/OverlayRuntime/ManualVerification.stories.tsx +++ /dev/null @@ -1,726 +0,0 @@ -import * as React from 'react'; - -import { Button, makeStyles, tokens } from '@fluentui/react-components'; -import { - Dialog, - DialogActions, - DialogBody, - DialogSurface, - DialogTitle, - DialogTrigger, -} from '@fluentui/react-headless-components-preview/dialog'; -import { Dropdown, Option } from '@fluentui/react-headless-components-preview/dropdown'; -import { Menu, MenuItem, MenuList, MenuPopover, MenuTrigger } from '@fluentui/react-headless-components-preview/menu'; -import { Popover, PopoverSurface, PopoverTrigger } from '@fluentui/react-headless-components-preview/popover'; -import { Toast, Toaster, ToastTitle, useToastController } from '@fluentui/react-headless-components-preview/toast'; -import { Tooltip } from '@fluentui/react-headless-components-preview/tooltip'; - -type RuntimeMode = 'auto' | 'native' | 'fallback'; -type RuntimeWindow = Window & { - __FUI_HEADLESS_OVERLAY_RUNTIME_MODE__?: RuntimeMode; -}; - -const RUNTIME_QUERY_PARAM = 'overlayRuntime'; - -const useStyles = makeStyles({ - page: { - display: 'grid', - gap: tokens.spacingVerticalXXL, - minHeight: '100vh', - padding: tokens.spacingHorizontalXXL, - color: tokens.colorNeutralForeground1, - backgroundColor: tokens.colorNeutralBackground1, - }, - header: { - display: 'grid', - gap: tokens.spacingVerticalM, - maxWidth: '960px', - }, - title: { - margin: 0, - fontFamily: tokens.fontFamilyBase, - fontSize: tokens.fontSizeHero800, - lineHeight: tokens.lineHeightHero800, - }, - subtitle: { - margin: 0, - color: tokens.colorNeutralForeground2, - fontSize: tokens.fontSizeBase400, - lineHeight: tokens.lineHeightBase400, - }, - statusPanel: { - display: 'flex', - flexWrap: 'wrap', - alignItems: 'center', - gap: tokens.spacingHorizontalM, - padding: tokens.spacingHorizontalM, - border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, - borderRadius: tokens.borderRadiusLarge, - backgroundColor: tokens.colorNeutralBackground2, - }, - statusLabel: { - fontWeight: tokens.fontWeightSemibold, - }, - modeControls: { - display: 'flex', - flexWrap: 'wrap', - gap: tokens.spacingHorizontalS, - }, - grid: { - display: 'grid', - gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', - gap: tokens.spacingHorizontalL, - alignItems: 'start', - }, - card: { - display: 'grid', - gap: tokens.spacingVerticalM, - minWidth: 0, - padding: tokens.spacingHorizontalL, - border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, - borderRadius: tokens.borderRadiusLarge, - backgroundColor: tokens.colorNeutralBackground2, - boxShadow: tokens.shadow4, - }, - wideCard: { - gridColumn: '1 / -1', - }, - cardTitle: { - margin: 0, - fontSize: tokens.fontSizeBase500, - lineHeight: tokens.lineHeightBase500, - }, - cardCopy: { - margin: 0, - color: tokens.colorNeutralForeground2, - lineHeight: tokens.lineHeightBase400, - }, - row: { - display: 'flex', - flexWrap: 'wrap', - alignItems: 'center', - gap: tokens.spacingHorizontalM, - }, - column: { - display: 'flex', - flexDirection: 'column', - alignItems: 'flex-start', - gap: tokens.spacingVerticalS, - }, - surface: { - minWidth: '240px', - maxWidth: '340px', - margin: 0, - padding: tokens.spacingHorizontalL, - color: tokens.colorNeutralForeground1, - backgroundColor: tokens.colorNeutralBackground1, - border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, - borderRadius: tokens.borderRadiusLarge, - boxShadow: tokens.shadow16, - '& [data-arrow]': { - position: 'absolute', - width: '12px', - height: '12px', - backgroundColor: tokens.colorNeutralBackground1, - transform: 'rotate(45deg)', - }, - '&[data-placement^="above"] [data-arrow]': { - bottom: '-6px', - }, - '&[data-placement^="below"] [data-arrow]': { - top: '-6px', - }, - '&[data-placement^="before"] [data-arrow]': { - right: '-6px', - }, - '&[data-placement^="after"] [data-arrow]': { - left: '-6px', - }, - '&[data-placement="above"] [data-arrow], &[data-placement="below"] [data-arrow]': { - insetInline: 0, - marginInline: 'auto', - }, - '&[data-placement="before"] [data-arrow], &[data-placement="after"] [data-arrow]': { - insetBlock: 0, - marginBlock: 'auto', - }, - }, - nestedSurface: { - minWidth: '280px', - }, - menuSurface: { - display: 'flex', - flexDirection: 'column', - minWidth: '180px', - padding: tokens.spacingVerticalXS, - }, - menuList: { - display: 'flex', - flexDirection: 'column', - gap: tokens.spacingVerticalXXS, - outlineStyle: 'none', - }, - menuItem: { - padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`, - color: tokens.colorNeutralForeground1, - backgroundColor: 'transparent', - borderRadius: tokens.borderRadiusMedium, - textAlign: 'start', - cursor: 'pointer', - '&:hover, &:focus-visible': { - backgroundColor: tokens.colorNeutralBackground1Hover, - outline: `${tokens.strokeWidthThick} solid ${tokens.colorBrandStroke1}`, - }, - }, - tooltip: { - padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`, - color: tokens.colorNeutralForegroundInverted, - backgroundColor: tokens.colorNeutralBackgroundStatic, - borderRadius: tokens.borderRadiusMedium, - boxShadow: tokens.shadow8, - }, - dropdownRoot: { - position: 'relative', - display: 'flex', - width: '280px', - }, - dropdownButton: { - display: 'flex', - justifyContent: 'space-between', - width: '100%', - padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`, - color: tokens.colorNeutralForeground1, - backgroundColor: tokens.colorNeutralBackground1, - border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, - borderRadius: tokens.borderRadiusMedium, - }, - listbox: { - display: 'flex', - flexDirection: 'column', - minWidth: '280px', - maxHeight: '240px', - overflowY: 'auto', - padding: tokens.spacingVerticalXS, - color: tokens.colorNeutralForeground1, - backgroundColor: tokens.colorNeutralBackground1, - border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, - borderRadius: tokens.borderRadiusMedium, - boxShadow: tokens.shadow16, - }, - option: { - padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`, - borderRadius: tokens.borderRadiusMedium, - '&:hover, &[data-activedescendant-focusvisible]': { - backgroundColor: tokens.colorNeutralBackground1Hover, - }, - '&[data-selected]': { - color: tokens.colorBrandForeground1, - backgroundColor: tokens.colorBrandBackground2, - }, - }, - dialogSurface: { - position: 'fixed', - inset: 0, - width: 'min(92vw, 520px)', - maxHeight: '80vh', - margin: 'auto', - padding: tokens.spacingHorizontalXL, - color: tokens.colorNeutralForeground1, - backgroundColor: tokens.colorNeutralBackground1, - border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, - borderRadius: tokens.borderRadiusXLarge, - boxShadow: tokens.shadow64, - '&::backdrop': { - backgroundColor: tokens.colorBackgroundOverlay, - }, - }, - dialogBody: { - display: 'grid', - gap: tokens.spacingVerticalM, - }, - dialogTitle: { - margin: 0, - fontSize: tokens.fontSizeBase600, - lineHeight: tokens.lineHeightBase600, - }, - dialogActions: { - display: 'flex', - justifyContent: 'flex-end', - gap: tokens.spacingHorizontalS, - marginTop: tokens.spacingVerticalL, - }, - edgeCanvas: { - position: 'relative', - height: '280px', - overflow: 'hidden', - border: `${tokens.strokeWidthThin} dashed ${tokens.colorNeutralStroke2}`, - borderRadius: tokens.borderRadiusLarge, - backgroundColor: tokens.colorNeutralBackground3, - }, - edgeTrigger: { - position: 'absolute', - insetInlineEnd: tokens.spacingHorizontalM, - insetBlockEnd: tokens.spacingVerticalM, - }, - placement: { - display: 'inline-flex', - padding: `${tokens.spacingVerticalXXS} ${tokens.spacingHorizontalXS}`, - fontFamily: tokens.fontFamilyMonospace, - fontSize: tokens.fontSizeBase200, - color: tokens.colorBrandForeground1, - backgroundColor: tokens.colorBrandBackground2, - borderRadius: tokens.borderRadiusSmall, - }, - checklist: { - display: 'grid', - gap: tokens.spacingVerticalXS, - margin: 0, - paddingInlineStart: tokens.spacingHorizontalXL, - color: tokens.colorNeutralForeground2, - }, - probeTrigger: { - position: 'absolute', - width: '1px', - height: '1px', - overflow: 'hidden', - clip: 'rect(0 0 0 0)', - clipPath: 'inset(50%)', - whiteSpace: 'nowrap', - }, - toast: { - minWidth: '280px', - padding: tokens.spacingHorizontalM, - color: tokens.colorNeutralForeground1, - backgroundColor: tokens.colorNeutralBackground1, - border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, - borderRadius: tokens.borderRadiusLarge, - boxShadow: tokens.shadow16, - }, - toaster: { - position: 'fixed', - insetBlockStart: tokens.spacingVerticalL, - insetInlineEnd: tokens.spacingHorizontalL, - display: 'flex', - flexDirection: 'column', - gap: tokens.spacingVerticalS, - }, -}); - -const getRuntimeMode = (): RuntimeMode => { - const requested = new URLSearchParams(window.location.search).get(RUNTIME_QUERY_PARAM); - return requested === 'native' || requested === 'fallback' ? requested : 'auto'; -}; - -const reloadWithRuntime = (mode: RuntimeMode): void => { - const url = new URL(window.location.href); - if (mode === 'auto') { - url.searchParams.delete(RUNTIME_QUERY_PARAM); - } else { - url.searchParams.set(RUNTIME_QUERY_PARAM, mode); - } - window.location.assign(url.toString()); -}; - -const usePlacementReadout = (): [React.RefCallback, string] => { - const [element, setElement] = React.useState(null); - const [placement, setPlacement] = React.useState('not mounted'); - - React.useEffect(() => { - if (!element) { - setPlacement('not mounted'); - return; - } - - const update = () => setPlacement(element.getAttribute('data-placement') ?? 'pending'); - update(); - - const observer = new MutationObserver(update); - observer.observe(element, { - attributes: true, - attributeFilter: ['data-placement'], - }); - - return () => observer.disconnect(); - }, [element]); - - return [setElement, placement]; -}; - -const RuntimeSentinel = (props: { onRuntimeChange: (runtime: string) => void }): React.ReactElement => { - const { onRuntimeChange } = props; - const styles = useStyles(); - const [element, setElement] = React.useState(null); - - React.useEffect(() => { - if (!element) { - return; - } - - const update = () => onRuntimeChange(element.getAttribute('data-overlay-runtime') ?? 'pending'); - update(); - - const observer = new MutationObserver(update); - observer.observe(element, { - attributes: true, - attributeFilter: ['data-overlay-runtime'], - }); - - return () => observer.disconnect(); - }, [element, onRuntimeChange]); - - return ( - - Runtime probe -
- } - relationship="description" - > - - - ); -}; - -const RuntimeControls = (props: { requestedMode: RuntimeMode; resolvedMode: string }): React.ReactElement => { - const { requestedMode, resolvedMode } = props; - const styles = useStyles(); - - return ( -
- - Requested:{' '} - {requestedMode} - - - Resolved:{' '} - {resolvedMode} - -
- {(['auto', 'native', 'fallback'] as const).map(mode => ( - - ))} -
-
- ); -}; - -const BasicPopoverExample = (): React.ReactElement => { - const styles = useStyles(); - const [open, setOpen] = React.useState(false); - const [surfaceRef, placement] = usePlacementReadout(); - - return ( -
-

Basic Popover

-

- Verify click, outside click, Escape, arrow placement, focus restoration, and controlled close. -

-
- setOpen(data.open)} positioning="below-start" withArrow> - - - - -
- Adaptive popover - - Placement:{' '} - - {placement} - - - -
-
-
- {open ? 'open' : 'closed'} -
-
- ); -}; - -const EdgePlacementExample = (): React.ReactElement => { - const styles = useStyles(); - const [surfaceRef, placement] = usePlacementReadout(); - - return ( -
-

Collision and fallback placement

-

- The requested placement is below-end at the canvas corner. It should resolve to a fitting fallback and keep the - surface inside the viewport. -

-
- - - - - -
- Collision test - - Resolved placement:{' '} - - {placement} - - -
-
-
-
-
- ); -}; - -const NestedPopoverExample = (): React.ReactElement => { - const styles = useStyles(); - - return ( -
-

Nested Popovers

-

- Open both surfaces, then press Escape. The inner surface should close first while the outer remains open. -

- - - - - -
- Outer popover - - - - - - Inner popover - - -
-
-
-
- ); -}; - -const MenuAndTooltipExample = (): React.ReactElement => { - const styles = useStyles(); - - return ( -
-

Menu and Tooltip

-

Verify menu focus/outside dismissal and tooltip hover, focus, and Escape.

-
- - - - - - - New file - Open file - Save - - - - - Adaptive tooltip -
- } - relationship="description" - showDelay={0} - > - - -
- - ); -}; - -const DropdownExample = (): React.ReactElement => { - const styles = useStyles(); - - return ( -
-

Dropdown

-

- Verify listbox portal placement, keyboard navigation, selection, and outside dismissal. -

- - - - - -
- ); -}; - -const DialogExample = (): React.ReactElement => { - const styles = useStyles(); - - return ( -
-

Modal Dialog

-

- Verify backdrop, initial focus, Tab trapping, Escape, close action, and focus restoration. -

- - - - - - - Adaptive modal dialog - Tab through both actions. Focus should remain in the dialog. - - - - - - - - - -
- ); -}; - -const ToastExample = (): React.ReactElement => { - const styles = useStyles(); - const toasterId = 'overlay-runtime-manual-toaster'; - const { dispatchToast } = useToastController(toasterId); - - const notify = () => { - dispatchToast( - - Adaptive overlay toast - , - { - intent: 'success', - position: 'top-end', - timeout: 10_000, - toastId: 'overlay-runtime-toast', - }, - ); - }; - - return ( -
-

Toaster

-

- Verify the toast position container renders in the top layer or fallback Portal. -

- - -
- ); -}; - -const ManualChecklist = (): React.ReactElement => { - const styles = useStyles(); - - return ( -
-

Manual verification checklist

-
    -
  1. Run the checklist once in forced native and once in forced fallback.
  2. -
  3. Confirm the resolved runtime matches the requested mode.
  4. -
  5. Confirm outside click and Escape dismiss only the expected overlay.
  6. -
  7. Confirm focus returns to each trigger after dismissal.
  8. -
  9. Confirm the edge popover flips and its placement readout updates.
  10. -
  11. Confirm nested Escape closes the inner popover before the outer.
  12. -
  13. Confirm the modal backdrop, Tab behavior, and toast placement.
  14. -
-
- ); -}; - -const OverlayPlayground = (props: { requestedMode: RuntimeMode }): React.ReactElement => { - const { requestedMode } = props; - const styles = useStyles(); - const [resolvedMode, setResolvedMode] = React.useState('pending'); - - return ( -
- - -
-

Headless overlay runtime verification

-

- This private story exercises the native Popover/Anchor runtime and the lazy Portal/Floating UI fallback - through the same component APIs. -

- -
-
- - - - - - - - -
-
- ); -}; - -const RuntimeGate = (): React.ReactElement => { - const [requestedMode, setRequestedMode] = React.useState(null); - - React.useEffect(() => { - const mode = getRuntimeMode(); - (window as RuntimeWindow).__FUI_HEADLESS_OVERLAY_RUNTIME_MODE__ = mode; - setRequestedMode(mode); - }, []); - - if (!requestedMode) { - return
Initializing overlay runtime...
; - } - - return ; -}; - -export const ManualVerification = (): React.ReactElement => ; diff --git a/apps/ssr-tests-v9/src/stories/OverlayRuntime/index.stories.tsx b/apps/ssr-tests-v9/src/stories/OverlayRuntime/index.stories.tsx deleted file mode 100644 index da2e179489d162..00000000000000 --- a/apps/ssr-tests-v9/src/stories/OverlayRuntime/index.stories.tsx +++ /dev/null @@ -1,5 +0,0 @@ -export { ManualVerification } from './ManualVerification.stories'; - -export default { - title: 'Overlay Runtime', -}; diff --git a/yarn.lock b/yarn.lock index 8d1753444f6f1c..ee3c4949b5ab1e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3213,6 +3213,15 @@ __metadata: languageName: unknown linkType: soft +"@fluentui/popover-tests-headless@workspace:apps/popover-tests-headless": + version: 0.0.0-use.local + resolution: "@fluentui/popover-tests-headless@workspace:apps/popover-tests-headless" + dependencies: + "@fluentui/react-components": "npm:*" + "@fluentui/react-headless-components-preview": "npm:*" + languageName: unknown + linkType: soft + "@fluentui/pr-deploy-site@workspace:apps/pr-deploy-site": version: 0.0.0-use.local resolution: "@fluentui/pr-deploy-site@workspace:apps/pr-deploy-site" @@ -6281,7 +6290,6 @@ __metadata: resolution: "@fluentui/ssr-tests-v9@workspace:apps/ssr-tests-v9" dependencies: "@fluentui/react-components": "npm:*" - "@fluentui/react-headless-components-preview": "npm:*" "@fluentui/react-utilities": "npm:*" languageName: unknown linkType: soft From 397926c00dd18952570da51e23c751a6d6b44c9d Mon Sep 17 00:00:00 2001 From: Sean Monahan Date: Mon, 10 Aug 2026 20:58:17 +0000 Subject: [PATCH 08/11] feat(popover-tests-headless): add SSR and CSR playground Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/popover-tests-headless/.npmignore | 29 + .../popover-tests-headless/.storybook/main.js | 10 + .../.storybook/preview.js | 9 + .../.storybook/tsconfig.json | 10 + apps/popover-tests-headless/README.md | 50 ++ apps/popover-tests-headless/config/tests.js | 3 + apps/popover-tests-headless/eslint.config.js | 14 + apps/popover-tests-headless/jest.config.js | 15 + apps/popover-tests-headless/just.config.ts | 3 + apps/popover-tests-headless/package.json | 24 + apps/popover-tests-headless/project.json | 8 + .../ManualVerification.stories.tsx | 726 ++++++++++++++++++ .../OverlayRuntime/ServerRendered.stories.tsx | 152 ++++ .../stories/OverlayRuntime/index.stories.tsx | 6 + apps/popover-tests-headless/tsconfig.json | 26 + apps/popover-tests-headless/tsconfig.lib.json | 14 + .../popover-tests-headless/tsconfig.spec.json | 8 + 17 files changed, 1107 insertions(+) create mode 100644 apps/popover-tests-headless/.npmignore create mode 100644 apps/popover-tests-headless/.storybook/main.js create mode 100644 apps/popover-tests-headless/.storybook/preview.js create mode 100644 apps/popover-tests-headless/.storybook/tsconfig.json create mode 100644 apps/popover-tests-headless/README.md create mode 100644 apps/popover-tests-headless/config/tests.js create mode 100644 apps/popover-tests-headless/eslint.config.js create mode 100644 apps/popover-tests-headless/jest.config.js create mode 100644 apps/popover-tests-headless/just.config.ts create mode 100644 apps/popover-tests-headless/package.json create mode 100644 apps/popover-tests-headless/project.json create mode 100644 apps/popover-tests-headless/src/stories/OverlayRuntime/ManualVerification.stories.tsx create mode 100644 apps/popover-tests-headless/src/stories/OverlayRuntime/ServerRendered.stories.tsx create mode 100644 apps/popover-tests-headless/src/stories/OverlayRuntime/index.stories.tsx create mode 100644 apps/popover-tests-headless/tsconfig.json create mode 100644 apps/popover-tests-headless/tsconfig.lib.json create mode 100644 apps/popover-tests-headless/tsconfig.spec.json diff --git a/apps/popover-tests-headless/.npmignore b/apps/popover-tests-headless/.npmignore new file mode 100644 index 00000000000000..52d2a7273a151d --- /dev/null +++ b/apps/popover-tests-headless/.npmignore @@ -0,0 +1,29 @@ +.storybook/ +.vscode/ +bundle-size/ +config/ +coverage/ +e2e/ +etc/ +node_modules/ +src/ +dist/types/ +temp/ +__fixtures__ +__mocks__ +__tests__ + +*.api.json +*.log +*.spec.* +*.stories.* +*.test.* +*.yml + +# config files +*config.* +*rc.* +.editorconfig +.eslint* +.git* +.prettierignore diff --git a/apps/popover-tests-headless/.storybook/main.js b/apps/popover-tests-headless/.storybook/main.js new file mode 100644 index 00000000000000..26d0ea6918ccea --- /dev/null +++ b/apps/popover-tests-headless/.storybook/main.js @@ -0,0 +1,10 @@ +const rootMain = require('../../../.storybook/main'); + +module.exports = /** @type {Omit} */ ({ + ...rootMain, + stories: [...rootMain.stories, '../src/**/*.mdx', '../src/**/index.stories.@(ts|tsx)'], + addons: [...rootMain.addons], + webpackFinal: (config, options) => { + return { ...rootMain.webpackFinal(config, options) }; + }, +}); diff --git a/apps/popover-tests-headless/.storybook/preview.js b/apps/popover-tests-headless/.storybook/preview.js new file mode 100644 index 00000000000000..35fc35c83b7b40 --- /dev/null +++ b/apps/popover-tests-headless/.storybook/preview.js @@ -0,0 +1,9 @@ +import * as rootPreview from '../../../.storybook/preview'; + +/** @type {typeof rootPreview.decorators} */ +export const decorators = [...rootPreview.decorators]; + +/** @type {typeof rootPreview.parameters} */ +export const parameters = { ...rootPreview.parameters }; + +export const tags = ['autodocs']; diff --git a/apps/popover-tests-headless/.storybook/tsconfig.json b/apps/popover-tests-headless/.storybook/tsconfig.json new file mode 100644 index 00000000000000..fb6d443db30d0d --- /dev/null +++ b/apps/popover-tests-headless/.storybook/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "", + "allowJs": true, + "checkJs": true, + "types": ["static-assets", "environment"] + }, + "include": ["../src/**/*.stories.ts", "../src/**/*.stories.tsx", "*.js"] +} diff --git a/apps/popover-tests-headless/README.md b/apps/popover-tests-headless/README.md new file mode 100644 index 00000000000000..dae16289131086 --- /dev/null +++ b/apps/popover-tests-headless/README.md @@ -0,0 +1,50 @@ +# @fluentui/popover-tests-headless + +Private test application for the Headless overlay runtime. + +The app exercises both implementations behind the same component APIs: + +- native HTML Popover/Dialog and CSS Anchor Positioning; +- lazy Portal, Tabster, and `react-positioning` fallback. + +No Playwright tests are included yet. The initial workflow is manual +verification, followed by Playwright coverage after the scenarios are approved. + +## CSR manual verification + +Start the private Storybook from the repository root: + +```bash +yarn nx storybook popover-tests-headless +``` + +Open: + +```text +http://localhost:6006/?path=/story/overlay-runtime--manual-verification +``` + +The story provides reload controls for: + +- auto-detected mode; +- forced native mode; +- forced fallback mode. + +It covers Popover placement and nesting, Menu, Tooltip, Dropdown, Dialog, and +Toast. Stable `data-testid` hooks are included for future Playwright tests. + +## SSR verification + +Run the SSR harness: + +```bash +yarn nx run popover-tests-headless:test-ssr +``` + +The **Overlay Runtime / Server Rendered** story intentionally renders open +Popover, Menu, Tooltip, Dropdown, and Dialog state on the server. The same story +can also be opened in Storybook to inspect client hydration: + +```text +http://localhost:6006/?path=/story/overlay-runtime--server-rendered +``` diff --git a/apps/popover-tests-headless/config/tests.js b/apps/popover-tests-headless/config/tests.js new file mode 100644 index 00000000000000..c6c67de97059e8 --- /dev/null +++ b/apps/popover-tests-headless/config/tests.js @@ -0,0 +1,3 @@ +/** Jest test setup file. */ + +require('@testing-library/jest-dom'); diff --git a/apps/popover-tests-headless/eslint.config.js b/apps/popover-tests-headless/eslint.config.js new file mode 100644 index 00000000000000..01494481e8e631 --- /dev/null +++ b/apps/popover-tests-headless/eslint.config.js @@ -0,0 +1,14 @@ +// @ts-check +const fluentPlugin = require('@fluentui/eslint-plugin'); + +/** @type {import("eslint").Linter.Config[]} */ +module.exports = [ + ...fluentPlugin.configs['flat/node'], + { + rules: { + '@typescript-eslint/explicit-module-boundary-types': 'off', + 'import/no-extraneous-dependencies': ['error', { packageDir: ['../../', './'] }], + 'no-restricted-globals': 'off', + }, + }, +]; diff --git a/apps/popover-tests-headless/jest.config.js b/apps/popover-tests-headless/jest.config.js new file mode 100644 index 00000000000000..3df8a3b1a49d54 --- /dev/null +++ b/apps/popover-tests-headless/jest.config.js @@ -0,0 +1,15 @@ +// @ts-check + +/** + * @type {import('@jest/types').Config.InitialOptions} + */ +module.exports = { + displayName: 'popover-tests-headless', + preset: '../../jest.preset.js', + transform: { + '^.+\\.tsx?$': ['@swc/jest', {}], + }, + testEnvironment: 'jsdom', + coverageDirectory: './coverage', + setupFilesAfterEnv: ['./config/tests.js'], +}; diff --git a/apps/popover-tests-headless/just.config.ts b/apps/popover-tests-headless/just.config.ts new file mode 100644 index 00000000000000..b10db31a6aca51 --- /dev/null +++ b/apps/popover-tests-headless/just.config.ts @@ -0,0 +1,3 @@ +import { preset } from '@fluentui/scripts-tasks'; + +preset(); diff --git a/apps/popover-tests-headless/package.json b/apps/popover-tests-headless/package.json new file mode 100644 index 00000000000000..a35c222d987072 --- /dev/null +++ b/apps/popover-tests-headless/package.json @@ -0,0 +1,24 @@ +{ + "name": "@fluentui/popover-tests-headless", + "version": "0.0.0", + "description": "Private SSR and CSR verification app for Headless overlays", + "private": true, + "repository": { + "type": "git", + "url": "https://github.com/microsoft/fluentui" + }, + "license": "MIT", + "scripts": { + "clean": "yarn run -T just-scripts clean", + "code-style": "yarn run -T just-scripts code-style", + "lint": "yarn run -T just-scripts lint", + "storybook": "yarn run -T storybook dev", + "test": "yarn run -T jest --passWithNoTests", + "type-check": "yarn run -T just-scripts type-check", + "test-ssr": "yarn run -T test-ssr \"./src/stories/**/*.stories.tsx\"" + }, + "dependencies": { + "@fluentui/react-components": "*", + "@fluentui/react-headless-components-preview": "*" + } +} diff --git a/apps/popover-tests-headless/project.json b/apps/popover-tests-headless/project.json new file mode 100644 index 00000000000000..5a48f40f6f2fb6 --- /dev/null +++ b/apps/popover-tests-headless/project.json @@ -0,0 +1,8 @@ +{ + "name": "popover-tests-headless", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "application", + "implicitDependencies": [], + "sourceRoot": "apps/popover-tests-headless/src", + "tags": ["vNext", "platform:web"] +} diff --git a/apps/popover-tests-headless/src/stories/OverlayRuntime/ManualVerification.stories.tsx b/apps/popover-tests-headless/src/stories/OverlayRuntime/ManualVerification.stories.tsx new file mode 100644 index 00000000000000..fad89beb412b4f --- /dev/null +++ b/apps/popover-tests-headless/src/stories/OverlayRuntime/ManualVerification.stories.tsx @@ -0,0 +1,726 @@ +import * as React from 'react'; + +import { Button, makeStyles, tokens } from '@fluentui/react-components'; +import { + Dialog, + DialogActions, + DialogBody, + DialogSurface, + DialogTitle, + DialogTrigger, +} from '@fluentui/react-headless-components-preview/dialog'; +import { Dropdown, Option } from '@fluentui/react-headless-components-preview/dropdown'; +import { Menu, MenuItem, MenuList, MenuPopover, MenuTrigger } from '@fluentui/react-headless-components-preview/menu'; +import { Popover, PopoverSurface, PopoverTrigger } from '@fluentui/react-headless-components-preview/popover'; +import { Toast, Toaster, ToastTitle, useToastController } from '@fluentui/react-headless-components-preview/toast'; +import { Tooltip } from '@fluentui/react-headless-components-preview/tooltip'; + +type RuntimeMode = 'auto' | 'native' | 'fallback'; +type RuntimeWindow = Window & { + __FUI_HEADLESS_OVERLAY_RUNTIME_MODE__?: RuntimeMode; +}; + +const RUNTIME_QUERY_PARAM = 'overlayRuntime'; + +const useStyles = makeStyles({ + page: { + display: 'grid', + gap: tokens.spacingVerticalXXL, + minHeight: '100vh', + padding: tokens.spacingHorizontalXXL, + color: tokens.colorNeutralForeground1, + backgroundColor: tokens.colorNeutralBackground1, + }, + header: { + display: 'grid', + gap: tokens.spacingVerticalM, + maxWidth: '960px', + }, + title: { + margin: 0, + fontFamily: tokens.fontFamilyBase, + fontSize: tokens.fontSizeHero800, + lineHeight: tokens.lineHeightHero800, + }, + subtitle: { + margin: 0, + color: tokens.colorNeutralForeground2, + fontSize: tokens.fontSizeBase400, + lineHeight: tokens.lineHeightBase400, + }, + statusPanel: { + display: 'flex', + flexWrap: 'wrap', + alignItems: 'center', + gap: tokens.spacingHorizontalM, + padding: tokens.spacingHorizontalM, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, + borderRadius: tokens.borderRadiusLarge, + backgroundColor: tokens.colorNeutralBackground2, + }, + statusLabel: { + fontWeight: tokens.fontWeightSemibold, + }, + modeControls: { + display: 'flex', + flexWrap: 'wrap', + gap: tokens.spacingHorizontalS, + }, + grid: { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', + gap: tokens.spacingHorizontalL, + alignItems: 'start', + }, + card: { + display: 'grid', + gap: tokens.spacingVerticalM, + minWidth: 0, + padding: tokens.spacingHorizontalL, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, + borderRadius: tokens.borderRadiusLarge, + backgroundColor: tokens.colorNeutralBackground2, + boxShadow: tokens.shadow4, + }, + wideCard: { + gridColumn: '1 / -1', + }, + cardTitle: { + margin: 0, + fontSize: tokens.fontSizeBase500, + lineHeight: tokens.lineHeightBase500, + }, + cardCopy: { + margin: 0, + color: tokens.colorNeutralForeground2, + lineHeight: tokens.lineHeightBase400, + }, + row: { + display: 'flex', + flexWrap: 'wrap', + alignItems: 'center', + gap: tokens.spacingHorizontalM, + }, + column: { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: tokens.spacingVerticalS, + }, + surface: { + minWidth: '240px', + maxWidth: '340px', + margin: 0, + padding: tokens.spacingHorizontalL, + color: tokens.colorNeutralForeground1, + backgroundColor: tokens.colorNeutralBackground1, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, + borderRadius: tokens.borderRadiusLarge, + boxShadow: tokens.shadow16, + '& [data-arrow]': { + position: 'absolute', + width: '12px', + height: '12px', + backgroundColor: tokens.colorNeutralBackground1, + transform: 'rotate(45deg)', + }, + '&[data-placement^="above"] [data-arrow]': { + bottom: '-6px', + }, + '&[data-placement^="below"] [data-arrow]': { + top: '-6px', + }, + '&[data-placement^="before"] [data-arrow]': { + right: '-6px', + }, + '&[data-placement^="after"] [data-arrow]': { + left: '-6px', + }, + '&[data-placement="above"] [data-arrow], &[data-placement="below"] [data-arrow]': { + insetInline: 0, + marginInline: 'auto', + }, + '&[data-placement="before"] [data-arrow], &[data-placement="after"] [data-arrow]': { + insetBlock: 0, + marginBlock: 'auto', + }, + }, + nestedSurface: { + minWidth: '280px', + }, + menuSurface: { + display: 'flex', + flexDirection: 'column', + minWidth: '180px', + padding: tokens.spacingVerticalXS, + }, + menuList: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXXS, + outlineStyle: 'none', + }, + menuItem: { + padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`, + color: tokens.colorNeutralForeground1, + backgroundColor: 'transparent', + borderRadius: tokens.borderRadiusMedium, + textAlign: 'start', + cursor: 'pointer', + '&:hover, &:focus-visible': { + backgroundColor: tokens.colorNeutralBackground1Hover, + outline: `${tokens.strokeWidthThick} solid ${tokens.colorBrandStroke1}`, + }, + }, + tooltip: { + padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`, + color: tokens.colorNeutralForegroundInverted, + backgroundColor: tokens.colorNeutralBackgroundStatic, + borderRadius: tokens.borderRadiusMedium, + boxShadow: tokens.shadow8, + }, + dropdownRoot: { + position: 'relative', + display: 'flex', + width: '280px', + }, + dropdownButton: { + display: 'flex', + justifyContent: 'space-between', + width: '100%', + padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`, + color: tokens.colorNeutralForeground1, + backgroundColor: tokens.colorNeutralBackground1, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, + borderRadius: tokens.borderRadiusMedium, + }, + listbox: { + display: 'flex', + flexDirection: 'column', + minWidth: '280px', + maxHeight: '240px', + overflowY: 'auto', + padding: tokens.spacingVerticalXS, + color: tokens.colorNeutralForeground1, + backgroundColor: tokens.colorNeutralBackground1, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, + borderRadius: tokens.borderRadiusMedium, + boxShadow: tokens.shadow16, + }, + option: { + padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`, + borderRadius: tokens.borderRadiusMedium, + '&:hover, &[data-activedescendant-focusvisible]': { + backgroundColor: tokens.colorNeutralBackground1Hover, + }, + '&[data-selected]': { + color: tokens.colorBrandForeground1, + backgroundColor: tokens.colorBrandBackground2, + }, + }, + dialogSurface: { + position: 'fixed', + inset: 0, + width: 'min(92vw, 520px)', + maxHeight: '80vh', + margin: 'auto', + padding: tokens.spacingHorizontalXL, + color: tokens.colorNeutralForeground1, + backgroundColor: tokens.colorNeutralBackground1, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, + borderRadius: tokens.borderRadiusXLarge, + boxShadow: tokens.shadow64, + '&::backdrop': { + backgroundColor: tokens.colorBackgroundOverlay, + }, + }, + dialogBody: { + display: 'grid', + gap: tokens.spacingVerticalM, + }, + dialogTitle: { + margin: 0, + fontSize: tokens.fontSizeBase600, + lineHeight: tokens.lineHeightBase600, + }, + dialogActions: { + display: 'flex', + justifyContent: 'flex-end', + gap: tokens.spacingHorizontalS, + marginTop: tokens.spacingVerticalL, + }, + edgeCanvas: { + position: 'relative', + height: '280px', + overflow: 'hidden', + border: `${tokens.strokeWidthThin} dashed ${tokens.colorNeutralStroke2}`, + borderRadius: tokens.borderRadiusLarge, + backgroundColor: tokens.colorNeutralBackground3, + }, + edgeTrigger: { + position: 'absolute', + insetInlineEnd: tokens.spacingHorizontalM, + insetBlockEnd: tokens.spacingVerticalM, + }, + placement: { + display: 'inline-flex', + padding: `${tokens.spacingVerticalXXS} ${tokens.spacingHorizontalXS}`, + fontFamily: tokens.fontFamilyMonospace, + fontSize: tokens.fontSizeBase200, + color: tokens.colorBrandForeground1, + backgroundColor: tokens.colorBrandBackground2, + borderRadius: tokens.borderRadiusSmall, + }, + checklist: { + display: 'grid', + gap: tokens.spacingVerticalXS, + margin: 0, + paddingInlineStart: tokens.spacingHorizontalXL, + color: tokens.colorNeutralForeground2, + }, + probeTrigger: { + position: 'absolute', + width: '1px', + height: '1px', + overflow: 'hidden', + clip: 'rect(0 0 0 0)', + clipPath: 'inset(50%)', + whiteSpace: 'nowrap', + }, + toast: { + minWidth: '280px', + padding: tokens.spacingHorizontalM, + color: tokens.colorNeutralForeground1, + backgroundColor: tokens.colorNeutralBackground1, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, + borderRadius: tokens.borderRadiusLarge, + boxShadow: tokens.shadow16, + }, + toaster: { + position: 'fixed', + insetBlockStart: tokens.spacingVerticalL, + insetInlineEnd: tokens.spacingHorizontalL, + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalS, + }, +}); + +const getRuntimeMode = (): RuntimeMode => { + const requested = new URLSearchParams(window.location.search).get(RUNTIME_QUERY_PARAM); + return requested === 'native' || requested === 'fallback' ? requested : 'auto'; +}; + +const reloadWithRuntime = (mode: RuntimeMode): void => { + const url = new URL(window.location.href); + if (mode === 'auto') { + url.searchParams.delete(RUNTIME_QUERY_PARAM); + } else { + url.searchParams.set(RUNTIME_QUERY_PARAM, mode); + } + window.location.assign(url.toString()); +}; + +const usePlacementReadout = (): [React.RefCallback, string] => { + const [element, setElement] = React.useState(null); + const [placement, setPlacement] = React.useState('not mounted'); + + React.useEffect(() => { + if (!element) { + setPlacement('not mounted'); + return; + } + + const update = () => setPlacement(element.getAttribute('data-placement') ?? 'pending'); + update(); + + const observer = new MutationObserver(update); + observer.observe(element, { + attributes: true, + attributeFilter: ['data-placement'], + }); + + return () => observer.disconnect(); + }, [element]); + + return [setElement, placement]; +}; + +const RuntimeSentinel = (props: { onRuntimeChange: (runtime: string) => void }): React.ReactElement => { + const { onRuntimeChange } = props; + const styles = useStyles(); + const [element, setElement] = React.useState(null); + + React.useEffect(() => { + if (!element) { + return; + } + + const update = () => onRuntimeChange(element.getAttribute('data-overlay-runtime') ?? 'pending'); + update(); + + const observer = new MutationObserver(update); + observer.observe(element, { + attributes: true, + attributeFilter: ['data-overlay-runtime'], + }); + + return () => observer.disconnect(); + }, [element, onRuntimeChange]); + + return ( + + Runtime probe + + } + relationship="description" + > + + + ); +}; + +const RuntimeControls = (props: { requestedMode: RuntimeMode; resolvedMode: string }): React.ReactElement => { + const { requestedMode, resolvedMode } = props; + const styles = useStyles(); + + return ( +
+ + Requested:{' '} + {requestedMode} + + + Resolved:{' '} + {resolvedMode} + +
+ {(['auto', 'native', 'fallback'] as const).map(mode => ( + + ))} +
+
+ ); +}; + +const BasicPopoverExample = (): React.ReactElement => { + const styles = useStyles(); + const [open, setOpen] = React.useState(false); + const [surfaceRef, placement] = usePlacementReadout(); + + return ( +
+

Basic Popover

+

+ Verify click, outside click, Escape, arrow placement, focus restoration, and controlled close. +

+
+ setOpen(data.open)} positioning="below-start" withArrow> + + + + +
+ Adaptive popover + + Placement:{' '} + + {placement} + + + +
+
+
+ {open ? 'open' : 'closed'} +
+
+ ); +}; + +const EdgePlacementExample = (): React.ReactElement => { + const styles = useStyles(); + const [surfaceRef, placement] = usePlacementReadout(); + + return ( +
+

Collision and fallback placement

+

+ The requested placement is below-end at the canvas corner. It should resolve to a fitting fallback and keep the + surface inside the viewport. +

+
+ + + + + +
+ Collision test + + Resolved placement:{' '} + + {placement} + + +
+
+
+
+
+ ); +}; + +const NestedPopoverExample = (): React.ReactElement => { + const styles = useStyles(); + + return ( +
+

Nested Popovers

+

+ Open both surfaces, then press Escape. The inner surface should close first while the outer remains open. +

+ + + + + +
+ Outer popover + + + + + + Inner popover + + +
+
+
+
+ ); +}; + +const MenuAndTooltipExample = (): React.ReactElement => { + const styles = useStyles(); + + return ( +
+

Menu and Tooltip

+

Verify menu focus/outside dismissal and tooltip hover, focus, and Escape.

+
+ + + + + + + New file + Open file + Save + + + + + Adaptive tooltip +
+ } + relationship="description" + showDelay={0} + > + + + +
+ ); +}; + +const DropdownExample = (): React.ReactElement => { + const styles = useStyles(); + + return ( +
+

Dropdown

+

+ Verify listbox portal placement, keyboard navigation, selection, and outside dismissal. +

+ + + + + +
+ ); +}; + +const DialogExample = (): React.ReactElement => { + const styles = useStyles(); + + return ( +
+

Modal Dialog

+

+ Verify backdrop, initial focus, Tab trapping, Escape, close action, and focus restoration. +

+ + + + + + + Adaptive modal dialog + Tab through both actions. Focus should remain in the dialog. + + + + + + + + + +
+ ); +}; + +const ToastExample = (): React.ReactElement => { + const styles = useStyles(); + const toasterId = 'overlay-runtime-manual-toaster'; + const { dispatchToast } = useToastController(toasterId); + + const notify = () => { + dispatchToast( + + Adaptive overlay toast + , + { + intent: 'success', + position: 'top-end', + timeout: 10_000, + toastId: 'overlay-runtime-toast', + }, + ); + }; + + return ( +
+

Toaster

+

+ Verify the toast position container renders in the top layer or fallback Portal. +

+ + +
+ ); +}; + +const ManualChecklist = (): React.ReactElement => { + const styles = useStyles(); + + return ( +
+

Manual verification checklist

+
    +
  1. Run the checklist once in forced native and once in forced fallback.
  2. +
  3. Confirm the resolved runtime matches the requested mode.
  4. +
  5. Confirm outside click and Escape dismiss only the expected overlay.
  6. +
  7. Confirm focus returns to each trigger after dismissal.
  8. +
  9. Confirm the edge popover flips and its placement readout updates.
  10. +
  11. Confirm nested Escape closes the inner popover before the outer.
  12. +
  13. Confirm the modal backdrop, Tab behavior, and toast placement.
  14. +
+
+ ); +}; + +const OverlayPlayground = (props: { requestedMode: RuntimeMode }): React.ReactElement => { + const { requestedMode } = props; + const styles = useStyles(); + const [resolvedMode, setResolvedMode] = React.useState('pending'); + + return ( +
+ + +
+

Headless overlay runtime verification

+

+ This private story exercises the native Popover/Anchor runtime and the lazy Portal/Floating UI fallback + through the same component APIs. +

+ +
+
+ + + + + + + + +
+
+ ); +}; + +const RuntimeGate = (): React.ReactElement => { + const [requestedMode, setRequestedMode] = React.useState(null); + + React.useEffect(() => { + const mode = getRuntimeMode(); + (window as RuntimeWindow).__FUI_HEADLESS_OVERLAY_RUNTIME_MODE__ = mode; + setRequestedMode(mode); + }, []); + + if (!requestedMode) { + return
Initializing overlay runtime...
; + } + + return ; +}; + +export const ManualVerification = (): React.ReactElement => ; diff --git a/apps/popover-tests-headless/src/stories/OverlayRuntime/ServerRendered.stories.tsx b/apps/popover-tests-headless/src/stories/OverlayRuntime/ServerRendered.stories.tsx new file mode 100644 index 00000000000000..661482fc60c463 --- /dev/null +++ b/apps/popover-tests-headless/src/stories/OverlayRuntime/ServerRendered.stories.tsx @@ -0,0 +1,152 @@ +import * as React from 'react'; + +import { Button, makeStyles, tokens } from '@fluentui/react-components'; +import { Dialog, DialogBody, DialogSurface, DialogTitle } from '@fluentui/react-headless-components-preview/dialog'; +import { Dropdown, Option } from '@fluentui/react-headless-components-preview/dropdown'; +import { Menu, MenuItem, MenuList, MenuPopover, MenuTrigger } from '@fluentui/react-headless-components-preview/menu'; +import { Popover, PopoverSurface, PopoverTrigger } from '@fluentui/react-headless-components-preview/popover'; +import { Tooltip } from '@fluentui/react-headless-components-preview/tooltip'; + +const useStyles = makeStyles({ + page: { + display: 'grid', + gap: tokens.spacingVerticalL, + padding: tokens.spacingHorizontalXL, + color: tokens.colorNeutralForeground1, + backgroundColor: tokens.colorNeutralBackground1, + }, + row: { + display: 'flex', + flexWrap: 'wrap', + alignItems: 'flex-start', + gap: tokens.spacingHorizontalL, + }, + surface: { + minWidth: '220px', + margin: 0, + padding: tokens.spacingHorizontalM, + color: tokens.colorNeutralForeground1, + backgroundColor: tokens.colorNeutralBackground1, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, + borderRadius: tokens.borderRadiusLarge, + boxShadow: tokens.shadow16, + }, + menuSurface: { + display: 'flex', + flexDirection: 'column', + }, + menuList: { + display: 'flex', + flexDirection: 'column', + }, + menuItem: { + padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`, + }, + tooltip: { + padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`, + color: tokens.colorNeutralForegroundInverted, + backgroundColor: tokens.colorNeutralBackgroundStatic, + borderRadius: tokens.borderRadiusMedium, + }, + dropdown: { + width: '240px', + }, + listbox: { + display: 'flex', + flexDirection: 'column', + minWidth: '240px', + padding: tokens.spacingVerticalXS, + color: tokens.colorNeutralForeground1, + backgroundColor: tokens.colorNeutralBackground1, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, + }, + option: { + padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`, + }, + dialog: { + position: 'fixed', + inset: 0, + width: 'min(90vw, 480px)', + margin: 'auto', + padding: tokens.spacingHorizontalXL, + color: tokens.colorNeutralForeground1, + backgroundColor: tokens.colorNeutralBackground1, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, + borderRadius: tokens.borderRadiusXLarge, + }, +}); + +/** + * This story intentionally renders open overlay state on the server. The + * `test-ssr` target renders this file without a browser, while Storybook can + * hydrate the same markup for manual inspection. + */ +export const ServerRendered = (): React.ReactElement => { + const styles = useStyles(); + + return ( +
+

Server-rendered Headless overlays

+

+ These surfaces start open during server rendering. View the server HTML and then confirm Storybook hydrates + without errors. +

+
+ + + + + + Server-rendered Popover surface + + + + + + + + + + First item + Second item + + + + + + Server-rendered Tooltip +
+ } + relationship="description" + visible + > + + + + + + + + + + + + Server-rendered Dialog + This modal starts open during SSR. + + + + +
+ ); +}; diff --git a/apps/popover-tests-headless/src/stories/OverlayRuntime/index.stories.tsx b/apps/popover-tests-headless/src/stories/OverlayRuntime/index.stories.tsx new file mode 100644 index 00000000000000..98ce521398e326 --- /dev/null +++ b/apps/popover-tests-headless/src/stories/OverlayRuntime/index.stories.tsx @@ -0,0 +1,6 @@ +export { ManualVerification } from './ManualVerification.stories'; +export { ServerRendered } from './ServerRendered.stories'; + +export default { + title: 'Overlay Runtime', +}; diff --git a/apps/popover-tests-headless/tsconfig.json b/apps/popover-tests-headless/tsconfig.json new file mode 100644 index 00000000000000..f4971d39e8a485 --- /dev/null +++ b/apps/popover-tests-headless/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "CommonJS", + "target": "ES2019", + "noEmit": true, + "isolatedModules": true, + "importHelpers": true, + "jsx": "react", + "noUnusedLocals": true, + "preserveConstEnums": true + }, + "include": [], + "files": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + }, + { + "path": "./.storybook/tsconfig.json" + } + ] +} diff --git a/apps/popover-tests-headless/tsconfig.lib.json b/apps/popover-tests-headless/tsconfig.lib.json new file mode 100644 index 00000000000000..d6f931d85a702e --- /dev/null +++ b/apps/popover-tests-headless/tsconfig.lib.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "lib": ["ES2019", "dom"], + "outDir": "dist", + "declaration": true, + "declarationDir": "dist/types", + "inlineSources": true, + "types": ["static-assets", "environment", "node"] + }, + "exclude": ["**/*.spec.ts", "**/*.spec.tsx", "**/*.test.ts", "**/*.test.tsx"], + "include": ["./src/**/*.ts", "./src/**/*.tsx"] +} diff --git a/apps/popover-tests-headless/tsconfig.spec.json b/apps/popover-tests-headless/tsconfig.spec.json new file mode 100644 index 00000000000000..457c5258c305a5 --- /dev/null +++ b/apps/popover-tests-headless/tsconfig.spec.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "types": ["jest", "node"] + }, + "include": ["**/*.spec.ts", "**/*.spec.tsx", "**/*.test.ts", "**/*.test.tsx", "**/*.d.ts"] +} From 7bc405e9673e0d2e3a3dad1ed8de9f9c3226af55 Mon Sep 17 00:00:00 2001 From: Sean Monahan Date: Mon, 10 Aug 2026 21:06:17 +0000 Subject: [PATCH 09/11] refactor(popover-tests-headless): split control pages Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/popover-tests-headless/README.md | 37 ++- .../OverlayRuntime/ServerRendered.stories.tsx | 152 ------------ .../stories/OverlayRuntime/index.stories.tsx | 6 - .../Controls.stories.tsx} | 176 ++++++++------ .../OverlayRuntimeCSR/index.stories.tsx | 12 + .../OverlayRuntimeSSR/Controls.stories.tsx | 226 ++++++++++++++++++ .../OverlayRuntimeSSR/index.stories.tsx | 12 + 7 files changed, 383 insertions(+), 238 deletions(-) delete mode 100644 apps/popover-tests-headless/src/stories/OverlayRuntime/ServerRendered.stories.tsx delete mode 100644 apps/popover-tests-headless/src/stories/OverlayRuntime/index.stories.tsx rename apps/popover-tests-headless/src/stories/{OverlayRuntime/ManualVerification.stories.tsx => OverlayRuntimeCSR/Controls.stories.tsx} (85%) create mode 100644 apps/popover-tests-headless/src/stories/OverlayRuntimeCSR/index.stories.tsx create mode 100644 apps/popover-tests-headless/src/stories/OverlayRuntimeSSR/Controls.stories.tsx create mode 100644 apps/popover-tests-headless/src/stories/OverlayRuntimeSSR/index.stories.tsx diff --git a/apps/popover-tests-headless/README.md b/apps/popover-tests-headless/README.md index dae16289131086..d8e406e20edf7b 100644 --- a/apps/popover-tests-headless/README.md +++ b/apps/popover-tests-headless/README.md @@ -18,20 +18,25 @@ Start the private Storybook from the repository root: yarn nx storybook popover-tests-headless ``` -Open: +Each control has its own page under **Overlay Runtime / CSR**: -```text -http://localhost:6006/?path=/story/overlay-runtime--manual-verification -``` +| Control | Story URL | +| -------- | -------------------------------------------- | +| Popover | `?path=/story/overlay-runtime-csr--popover` | +| Menu | `?path=/story/overlay-runtime-csr--menu` | +| Tooltip | `?path=/story/overlay-runtime-csr--tooltip` | +| Dropdown | `?path=/story/overlay-runtime-csr--dropdown` | +| Dialog | `?path=/story/overlay-runtime-csr--dialog` | +| Toast | `?path=/story/overlay-runtime-csr--toast` | -The story provides reload controls for: +Every CSR page provides reload controls for: - auto-detected mode; - forced native mode; - forced fallback mode. -It covers Popover placement and nesting, Menu, Tooltip, Dropdown, Dialog, and -Toast. Stable `data-testid` hooks are included for future Playwright tests. +The Popover page includes basic, nested, and collision-placement scenarios. +Stable `data-testid` hooks are included for future Playwright tests. ## SSR verification @@ -41,10 +46,16 @@ Run the SSR harness: yarn nx run popover-tests-headless:test-ssr ``` -The **Overlay Runtime / Server Rendered** story intentionally renders open -Popover, Menu, Tooltip, Dropdown, and Dialog state on the server. The same story -can also be opened in Storybook to inspect client hydration: +Each control also has a separate **Overlay Runtime / SSR** page: -```text -http://localhost:6006/?path=/story/overlay-runtime--server-rendered -``` +| Control | Story URL | +| -------- | -------------------------------------------- | +| Popover | `?path=/story/overlay-runtime-ssr--popover` | +| Menu | `?path=/story/overlay-runtime-ssr--menu` | +| Tooltip | `?path=/story/overlay-runtime-ssr--tooltip` | +| Dropdown | `?path=/story/overlay-runtime-ssr--dropdown` | +| Dialog | `?path=/story/overlay-runtime-ssr--dialog` | +| Toast | `?path=/story/overlay-runtime-ssr--toast` | + +The SSR harness renders each `*.stories.tsx` export without a browser. The same +pages can be opened in Storybook to inspect client hydration. diff --git a/apps/popover-tests-headless/src/stories/OverlayRuntime/ServerRendered.stories.tsx b/apps/popover-tests-headless/src/stories/OverlayRuntime/ServerRendered.stories.tsx deleted file mode 100644 index 661482fc60c463..00000000000000 --- a/apps/popover-tests-headless/src/stories/OverlayRuntime/ServerRendered.stories.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import * as React from 'react'; - -import { Button, makeStyles, tokens } from '@fluentui/react-components'; -import { Dialog, DialogBody, DialogSurface, DialogTitle } from '@fluentui/react-headless-components-preview/dialog'; -import { Dropdown, Option } from '@fluentui/react-headless-components-preview/dropdown'; -import { Menu, MenuItem, MenuList, MenuPopover, MenuTrigger } from '@fluentui/react-headless-components-preview/menu'; -import { Popover, PopoverSurface, PopoverTrigger } from '@fluentui/react-headless-components-preview/popover'; -import { Tooltip } from '@fluentui/react-headless-components-preview/tooltip'; - -const useStyles = makeStyles({ - page: { - display: 'grid', - gap: tokens.spacingVerticalL, - padding: tokens.spacingHorizontalXL, - color: tokens.colorNeutralForeground1, - backgroundColor: tokens.colorNeutralBackground1, - }, - row: { - display: 'flex', - flexWrap: 'wrap', - alignItems: 'flex-start', - gap: tokens.spacingHorizontalL, - }, - surface: { - minWidth: '220px', - margin: 0, - padding: tokens.spacingHorizontalM, - color: tokens.colorNeutralForeground1, - backgroundColor: tokens.colorNeutralBackground1, - border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, - borderRadius: tokens.borderRadiusLarge, - boxShadow: tokens.shadow16, - }, - menuSurface: { - display: 'flex', - flexDirection: 'column', - }, - menuList: { - display: 'flex', - flexDirection: 'column', - }, - menuItem: { - padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`, - }, - tooltip: { - padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`, - color: tokens.colorNeutralForegroundInverted, - backgroundColor: tokens.colorNeutralBackgroundStatic, - borderRadius: tokens.borderRadiusMedium, - }, - dropdown: { - width: '240px', - }, - listbox: { - display: 'flex', - flexDirection: 'column', - minWidth: '240px', - padding: tokens.spacingVerticalXS, - color: tokens.colorNeutralForeground1, - backgroundColor: tokens.colorNeutralBackground1, - border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, - }, - option: { - padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`, - }, - dialog: { - position: 'fixed', - inset: 0, - width: 'min(90vw, 480px)', - margin: 'auto', - padding: tokens.spacingHorizontalXL, - color: tokens.colorNeutralForeground1, - backgroundColor: tokens.colorNeutralBackground1, - border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, - borderRadius: tokens.borderRadiusXLarge, - }, -}); - -/** - * This story intentionally renders open overlay state on the server. The - * `test-ssr` target renders this file without a browser, while Storybook can - * hydrate the same markup for manual inspection. - */ -export const ServerRendered = (): React.ReactElement => { - const styles = useStyles(); - - return ( -
-

Server-rendered Headless overlays

-

- These surfaces start open during server rendering. View the server HTML and then confirm Storybook hydrates - without errors. -

-
- - - - - - Server-rendered Popover surface - - - - - - - - - - First item - Second item - - - - - - Server-rendered Tooltip -
- } - relationship="description" - visible - > - - - - - - - - - - - - Server-rendered Dialog - This modal starts open during SSR. - - - - -
- ); -}; diff --git a/apps/popover-tests-headless/src/stories/OverlayRuntime/index.stories.tsx b/apps/popover-tests-headless/src/stories/OverlayRuntime/index.stories.tsx deleted file mode 100644 index 98ce521398e326..00000000000000 --- a/apps/popover-tests-headless/src/stories/OverlayRuntime/index.stories.tsx +++ /dev/null @@ -1,6 +0,0 @@ -export { ManualVerification } from './ManualVerification.stories'; -export { ServerRendered } from './ServerRendered.stories'; - -export default { - title: 'Overlay Runtime', -}; diff --git a/apps/popover-tests-headless/src/stories/OverlayRuntime/ManualVerification.stories.tsx b/apps/popover-tests-headless/src/stories/OverlayRuntimeCSR/Controls.stories.tsx similarity index 85% rename from apps/popover-tests-headless/src/stories/OverlayRuntime/ManualVerification.stories.tsx rename to apps/popover-tests-headless/src/stories/OverlayRuntimeCSR/Controls.stories.tsx index fad89beb412b4f..549e17e42ecaee 100644 --- a/apps/popover-tests-headless/src/stories/OverlayRuntime/ManualVerification.stories.tsx +++ b/apps/popover-tests-headless/src/stories/OverlayRuntimeCSR/Controls.stories.tsx @@ -526,38 +526,47 @@ const NestedPopoverExample = (): React.ReactElement => { ); }; -const MenuAndTooltipExample = (): React.ReactElement => { +const MenuExample = (): React.ReactElement => { const styles = useStyles(); return (
-

Menu and Tooltip

-

Verify menu focus/outside dismissal and tooltip hover, focus, and Escape.

-
- - - - - - - New file - Open file - Save - - - - - Adaptive tooltip -
- } - relationship="description" - showDelay={0} - > - - - +

Menu

+

Verify first-item focus, keyboard navigation, outside dismissal, and Escape.

+ + + + + + + New file + Open file + Save + + + +
+ ); +}; + +const TooltipExample = (): React.ReactElement => { + const styles = useStyles(); + + return ( +
+

Tooltip

+

Verify hover, keyboard focus, outside dismissal, and Escape.

+ + Adaptive tooltip + + } + relationship="description" + showDelay={0} + > + +
); }; @@ -657,27 +666,13 @@ const ToastExample = (): React.ReactElement => { ); }; -const ManualChecklist = (): React.ReactElement => { - const styles = useStyles(); - - return ( -
-

Manual verification checklist

-
    -
  1. Run the checklist once in forced native and once in forced fallback.
  2. -
  3. Confirm the resolved runtime matches the requested mode.
  4. -
  5. Confirm outside click and Escape dismiss only the expected overlay.
  6. -
  7. Confirm focus returns to each trigger after dismissal.
  8. -
  9. Confirm the edge popover flips and its placement readout updates.
  10. -
  11. Confirm nested Escape closes the inner popover before the outer.
  12. -
  13. Confirm the modal backdrop, Tab behavior, and toast placement.
  14. -
-
- ); -}; - -const OverlayPlayground = (props: { requestedMode: RuntimeMode }): React.ReactElement => { - const { requestedMode } = props; +const RuntimePage = (props: { + children: React.ReactNode; + description: string; + requestedMode: RuntimeMode; + title: string; +}): React.ReactElement => { + const { children, description, requestedMode, title } = props; const styles = useStyles(); const [resolvedMode, setResolvedMode] = React.useState('pending'); @@ -686,28 +681,17 @@ const OverlayPlayground = (props: { requestedMode: RuntimeMode }): React.ReactEl
-

Headless overlay runtime verification

-

- This private story exercises the native Popover/Anchor runtime and the lazy Portal/Floating UI fallback - through the same component APIs. -

+

{title}

+

{description}

-
- - - - - - - - -
+
{children}
); }; -const RuntimeGate = (): React.ReactElement => { +const RuntimeGate = (props: { children: React.ReactNode; description: string; title: string }): React.ReactElement => { + const { children, description, title } = props; const [requestedMode, setRequestedMode] = React.useState(null); React.useEffect(() => { @@ -720,7 +704,65 @@ const RuntimeGate = (): React.ReactElement => { return
Initializing overlay runtime...
; } - return ; + return ( + + {children} + + ); }; -export const ManualVerification = (): React.ReactElement => ; +export const PopoverControl = (): React.ReactElement => ( + + + + + +); + +export const MenuControl = (): React.ReactElement => ( + + + +); + +export const TooltipControl = (): React.ReactElement => ( + + + +); + +export const DropdownControl = (): React.ReactElement => ( + + + +); + +export const DialogControl = (): React.ReactElement => ( + + + +); + +export const ToastControl = (): React.ReactElement => ( + + + +); diff --git a/apps/popover-tests-headless/src/stories/OverlayRuntimeCSR/index.stories.tsx b/apps/popover-tests-headless/src/stories/OverlayRuntimeCSR/index.stories.tsx new file mode 100644 index 00000000000000..e5d06abd904303 --- /dev/null +++ b/apps/popover-tests-headless/src/stories/OverlayRuntimeCSR/index.stories.tsx @@ -0,0 +1,12 @@ +export { + DialogControl as Dialog, + DropdownControl as Dropdown, + MenuControl as Menu, + PopoverControl as Popover, + ToastControl as Toast, + TooltipControl as Tooltip, +} from './Controls.stories'; + +export default { + title: 'Overlay Runtime / CSR', +}; diff --git a/apps/popover-tests-headless/src/stories/OverlayRuntimeSSR/Controls.stories.tsx b/apps/popover-tests-headless/src/stories/OverlayRuntimeSSR/Controls.stories.tsx new file mode 100644 index 00000000000000..56ddd296c97953 --- /dev/null +++ b/apps/popover-tests-headless/src/stories/OverlayRuntimeSSR/Controls.stories.tsx @@ -0,0 +1,226 @@ +import * as React from 'react'; + +import { Button, makeStyles, tokens } from '@fluentui/react-components'; +import { Dialog, DialogBody, DialogSurface, DialogTitle } from '@fluentui/react-headless-components-preview/dialog'; +import { Dropdown, Option } from '@fluentui/react-headless-components-preview/dropdown'; +import { Menu, MenuItem, MenuList, MenuPopover, MenuTrigger } from '@fluentui/react-headless-components-preview/menu'; +import { Popover, PopoverSurface, PopoverTrigger } from '@fluentui/react-headless-components-preview/popover'; +import { Toast, Toaster, ToastTitle } from '@fluentui/react-headless-components-preview/toast'; +import { Tooltip } from '@fluentui/react-headless-components-preview/tooltip'; + +const useStyles = makeStyles({ + page: { + display: 'grid', + gap: tokens.spacingVerticalL, + padding: tokens.spacingHorizontalXL, + color: tokens.colorNeutralForeground1, + backgroundColor: tokens.colorNeutralBackground1, + }, + row: { + display: 'flex', + flexWrap: 'wrap', + alignItems: 'flex-start', + gap: tokens.spacingHorizontalL, + }, + surface: { + minWidth: '220px', + margin: 0, + padding: tokens.spacingHorizontalM, + color: tokens.colorNeutralForeground1, + backgroundColor: tokens.colorNeutralBackground1, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, + borderRadius: tokens.borderRadiusLarge, + boxShadow: tokens.shadow16, + }, + menuSurface: { + display: 'flex', + flexDirection: 'column', + }, + menuList: { + display: 'flex', + flexDirection: 'column', + }, + menuItem: { + padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`, + }, + tooltip: { + padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`, + color: tokens.colorNeutralForegroundInverted, + backgroundColor: tokens.colorNeutralBackgroundStatic, + borderRadius: tokens.borderRadiusMedium, + }, + dropdown: { + width: '240px', + }, + listbox: { + display: 'flex', + flexDirection: 'column', + minWidth: '240px', + padding: tokens.spacingVerticalXS, + color: tokens.colorNeutralForeground1, + backgroundColor: tokens.colorNeutralBackground1, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, + }, + option: { + padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`, + }, + dialog: { + position: 'fixed', + inset: 0, + width: 'min(90vw, 480px)', + margin: 'auto', + padding: tokens.spacingHorizontalXL, + color: tokens.colorNeutralForeground1, + backgroundColor: tokens.colorNeutralBackground1, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, + borderRadius: tokens.borderRadiusXLarge, + }, + toast: { + minWidth: '240px', + padding: tokens.spacingHorizontalM, + color: tokens.colorNeutralForeground1, + backgroundColor: tokens.colorNeutralBackground1, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, + borderRadius: tokens.borderRadiusLarge, + boxShadow: tokens.shadow16, + }, +}); + +const SsrPage = (props: { children: React.ReactNode; description: string; title: string }): React.ReactElement => { + const { children, description, title } = props; + const styles = useStyles(); + + return ( +
+

{title}

+

{description}

+
{children}
+
+ ); +}; + +export const PopoverControl = (): React.ReactElement => { + const styles = useStyles(); + + return ( + + + + + + + Server-rendered Popover surface + + + + ); +}; + +export const MenuControl = (): React.ReactElement => { + const styles = useStyles(); + + return ( + + + + + + + + First item + Second item + + + + + ); +}; + +export const TooltipControl = (): React.ReactElement => { + const styles = useStyles(); + + return ( + + + Server-rendered Tooltip + + } + relationship="description" + visible + > + + + + ); +}; + +export const DropdownControl = (): React.ReactElement => { + const styles = useStyles(); + + return ( + + + + + + + ); +}; + +export const DialogControl = (): React.ReactElement => { + const styles = useStyles(); + + return ( + + + + + Server-rendered Dialog + This modal starts open during SSR. + + + + + ); +}; + +export const ToastControl = (): React.ReactElement => { + const styles = useStyles(); + + return ( + + + + Server-rendered Toast + + + ); +}; diff --git a/apps/popover-tests-headless/src/stories/OverlayRuntimeSSR/index.stories.tsx b/apps/popover-tests-headless/src/stories/OverlayRuntimeSSR/index.stories.tsx new file mode 100644 index 00000000000000..668b641616f8f6 --- /dev/null +++ b/apps/popover-tests-headless/src/stories/OverlayRuntimeSSR/index.stories.tsx @@ -0,0 +1,12 @@ +export { + DialogControl as Dialog, + DropdownControl as Dropdown, + MenuControl as Menu, + PopoverControl as Popover, + ToastControl as Toast, + TooltipControl as Tooltip, +} from './Controls.stories'; + +export default { + title: 'Overlay Runtime / SSR', +}; From ed877da52b4093e1d6efa9454dc7f612c4037a02 Mon Sep 17 00:00:00 2001 From: Sean Monahan Date: Mon, 10 Aug 2026 21:36:50 +0000 Subject: [PATCH 10/11] feat(popover-tests-headless): add hydration runtime controls Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/popover-tests-headless/README.md | 9 ++ .../OverlayRuntimeCSR/Controls.stories.tsx | 10 +- .../OverlayRuntimeSSR/Controls.stories.tsx | 133 ++++++++++++++++++ 3 files changed, 147 insertions(+), 5 deletions(-) diff --git a/apps/popover-tests-headless/README.md b/apps/popover-tests-headless/README.md index d8e406e20edf7b..fdb9ae077a5b93 100644 --- a/apps/popover-tests-headless/README.md +++ b/apps/popover-tests-headless/README.md @@ -59,3 +59,12 @@ Each control also has a separate **Overlay Runtime / SSR** page: The SSR harness renders each `*.stories.tsx` export without a browser. The same pages can be opened in Storybook to inspect client hydration. + +Every SSR page reports: + +- server render mode: always `ssr`; +- requested hydration runtime: auto, native, or fallback; +- runtime resolved by the hydrated client. + +The hydration runtime buttons use the same `overlayRuntime` query parameter as +the CSR pages. They do not change the server render mode. diff --git a/apps/popover-tests-headless/src/stories/OverlayRuntimeCSR/Controls.stories.tsx b/apps/popover-tests-headless/src/stories/OverlayRuntimeCSR/Controls.stories.tsx index 549e17e42ecaee..9ce198986f1517 100644 --- a/apps/popover-tests-headless/src/stories/OverlayRuntimeCSR/Controls.stories.tsx +++ b/apps/popover-tests-headless/src/stories/OverlayRuntimeCSR/Controls.stories.tsx @@ -370,11 +370,11 @@ const RuntimeSentinel = (props: { onRuntimeChange: (runtime: string) => void }): return ( - Runtime probe - - } + content={{ + children: 'Runtime probe', + id: 'runtime-sentinel', + ref: setElement, + }} relationship="description" >