From 241d37845d3263dfe6683398b30617a9333d6d52 Mon Sep 17 00:00:00 2001 From: PaulGMardling Date: Mon, 26 Oct 2026 16:21:41 +0100 Subject: [PATCH 1/4] fix(headless): expose cover-target state for surface styling Expose a headless-owned data-cover-target marker from shared positioning, with lifecycle cleanup and consumer-attribute reconciliation. Cover component composition, nesting, geometry and keyboard behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...-c39cc8af-e3fe-431f-b463-98654880d0c9.json | 6 + .../library/src/components/Menu/Menu.cy.tsx | 122 ++++++++++++++++++ .../library/src/components/Menu/Menu.test.tsx | 108 +++++++++++++++- .../src/components/Popover/Popover.test.tsx | 20 +++ .../components/TagPicker/TagPicker.test.tsx | 1 + .../usePositioning/usePositioning.test.tsx | 110 ++++++++++++++++ .../hooks/usePositioning/usePositioning.ts | 26 ++++ .../Positioning/PositioningBestPractices.md | 22 ++++ 8 files changed, 414 insertions(+), 1 deletion(-) create mode 100644 change/@fluentui-react-headless-components-preview-c39cc8af-e3fe-431f-b463-98654880d0c9.json diff --git a/change/@fluentui-react-headless-components-preview-c39cc8af-e3fe-431f-b463-98654880d0c9.json b/change/@fluentui-react-headless-components-preview-c39cc8af-e3fe-431f-b463-98654880d0c9.json new file mode 100644 index 00000000000000..f57c4b7b9ba124 --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-c39cc8af-e3fe-431f-b463-98654880d0c9.json @@ -0,0 +1,6 @@ +{ + "type": "patch", + "comment": "fix: expose resolved coverTarget as a headless positioning data attribute for surface styling", + "packageName": "@fluentui/react-headless-components-preview", + "email": "paulmardling@microsoft.com" +} diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/Menu.cy.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Menu/Menu.cy.tsx index 1fb3793edfbd73..487e4acbb51fb2 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Menu/Menu.cy.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/Menu.cy.tsx @@ -3,10 +3,132 @@ import { mount as mountBase } from '@fluentui/scripts-cypress'; import { Menu, MenuPopover, MenuList, MenuItem, MenuTrigger } from './'; import { Tooltip } from '../Tooltip'; import type { JSXElement } from '@fluentui/react-utilities'; +import type { MenuProps } from './Menu.types'; const mount = (element: JSXElement) => mountBase(element); describe('Menu', () => { + describe('covering-state styling contract', () => { + let originalDir: string | null; + beforeEach(() => { + cy.document().then(doc => { + originalDir = doc.documentElement.getAttribute('dir'); + }); + }); + afterEach(() => { + cy.document().then(doc => { + if (originalDir === null) { + doc.documentElement.removeAttribute('dir'); + } else { + doc.documentElement.setAttribute('dir', originalDir); + } + }); + }); + + (['ltr', 'rtl'] as const).forEach(dir => { + (['above', 'below', 'before', 'after'] as const).forEach(position => { + it(`preserves ${position} geometry and offsets in ${dir}`, () => { + cy.document().then(doc => doc.documentElement.setAttribute('dir', dir)); + const offset = position === 'above' || position === 'below' ? 8 : { mainAxis: 8, crossAxis: 4 }; + const positioning: MenuProps['positioning'] = { + position, + align: 'center', + pinned: true, + offset, + fallbackPositions: ['below', 'above'], + }; + mount( +
+ + + + + + + First action + Second action + + + +
, + ); + cy.get('[data-testid=geometry-trigger]').focus().realPress('Enter'); + cy.get('[data-testid=geometry-surface]').should('be.visible').and('not.have.attr', 'data-cover-target'); + cy.get('[data-testid=geometry-trigger]').then(trigger => { + const target = trigger[0].getBoundingClientRect(); + cy.get('[data-testid=geometry-surface]').should(surface => { + const element = surface[0]; + const rect = element.getBoundingClientRect(); + expect(element.style.getPropertyValue('position-try-fallbacks')).to.equal(''); + expect(element.style.getPropertyValue('margin-block-start')).to.equal( + position === 'above' || position === 'below' ? '8px' : '4px', + ); + if (position === 'above') { + expect(target.top - rect.bottom).to.be.closeTo(8, 1); + } else if (position === 'below') { + expect(rect.top - target.bottom).to.be.closeTo(8, 1); + } else if ((position === 'before') === (dir === 'ltr')) { + expect(target.left - rect.right).to.be.closeTo(8, 1); + } else { + expect(rect.left - target.right).to.be.closeTo(8, 1); + } + }); + }); + }); + }); + }); + + ([false, true] as const).forEach(coverTarget => { + (['Enter', 'Space'] as const).forEach(key => { + it(`preserves keyboard lifecycle with coverTarget=${coverTarget}, opening with ${key}`, () => { + mount( + + + + + + + First action + Second action + + + , + ); + + cy.get('[data-testid=cover-trigger]').focus().realPress(key); + cy.get('[data-testid=cover-surface]').should('be.visible'); + cy.get('[data-testid=cover-surface]').should( + coverTarget ? 'have.attr' : 'not.have.attr', + 'data-cover-target', + ); + if (coverTarget) { + cy.get('[data-testid=cover-trigger]').then(trigger => { + cy.get('[data-testid=cover-surface]').should(surface => { + expect(surface[0].style.getPropertyValue('position-area')).to.equal('center'); + expect(surface[0].getBoundingClientRect().height).to.be.greaterThan( + trigger[0].getBoundingClientRect().height, + ); + }); + }); + } + cy.get('[data-testid=cover-trigger]').should('have.attr', 'aria-expanded', 'true'); + cy.get('[role=menu]').should('have.attr', 'aria-labelledby'); + cy.get('[data-testid=first-item]').should('have.focus'); + cy.realPress('ArrowDown'); + cy.get('[data-testid=second-item]').should('have.focus'); + cy.realPress('Escape'); + cy.get('[data-testid=cover-surface]').should('not.be.visible'); + cy.get('[data-testid=cover-trigger]').should('have.focus').realPress(key); + cy.get('[data-testid=cover-surface]').should('be.visible'); + cy.get('[data-testid=first-item]').should('have.focus'); + cy.realPress('Tab'); + cy.get('[data-testid=cover-surface]').should('not.be.visible'); + cy.get('[data-testid=cover-trigger]').should('not.have.attr', 'aria-expanded'); + }); + }); + }); + }); + describe('with Tooltip wrapping MenuTrigger', () => { const TooltipWrappedMenuExample = () => ( diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/Menu.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Menu/Menu.test.tsx index edc796224f924a..a3709c5bb062ea 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Menu/Menu.test.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/Menu.test.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; -import { render } from '@testing-library/react'; +import { fireEvent, render } from '@testing-library/react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; import userEvent from '@testing-library/user-event'; import { Menu } from './Menu'; import { MenuTrigger } from './MenuTrigger/MenuTrigger'; @@ -14,8 +15,113 @@ import { MenuDivider } from './MenuDivider/MenuDivider'; import { MenuGroup } from './MenuGroup/MenuGroup'; import { MenuGroupHeader } from './MenuGroupHeader/MenuGroupHeader'; import { MenuSplitGroup } from './MenuSplitGroup/MenuSplitGroup'; +import { renderMenu, renderMenuPopover, useMenu, useMenuContextValues, useMenuPopover } from '../../menu'; +import type { MenuProps, MenuPopoverProps } from '../../menu'; + +const ComposedMenu = (props: MenuProps) => { + const state = useMenu(props); + return renderMenu(state, useMenuContextValues(state)); +}; + +const ComposedMenuPopover: ForwardRefComponent = React.forwardRef((props, ref) => + renderMenuPopover(useMenuPopover(props, ref)), +); describe('Menu', () => { + describe.each([false, true])('positioning signal with public composition=%s', composed => { + const MenuComponent = composed ? ComposedMenu : Menu; + const Surface = composed ? ComposedMenuPopover : MenuPopover; + + it('owns the actual surface marker while preserving props, handlers and refs', () => { + const ref = React.createRef(); + const onKeyDown = jest.fn(); + const Example = ({ positioning, marker }: { positioning?: MenuProps['positioning']; marker?: string }) => ( + + + + + + + Item + + + + ); + const { getByTestId, getByRole, rerender, unmount } = render( + , + ); + const surface = getByTestId('surface'); + + expect(surface).toHaveAttribute('data-cover-target', ''); + expect(surface).toHaveAttribute('data-custom', 'retained'); + expect(surface).toHaveAttribute('popover', 'auto'); + expect(surface).toHaveAttribute('role', 'presentation'); + expect(ref.current).toBe(surface); + expect(getByRole('menu', { name: 'Trigger' })).toBeInTheDocument(); + expect(getByRole('button', { name: 'Trigger' })).toHaveAttribute('aria-expanded', 'true'); + fireEvent.keyDown(surface, { key: 'a' }); + expect(onKeyDown).toHaveBeenCalledTimes(1); + + rerender(); + expect(surface).toHaveAttribute('data-cover-target', ''); + rerender(); + expect(surface).not.toHaveAttribute('data-cover-target'); + rerender(); + expect(surface).toHaveAttribute('data-cover-target', ''); + rerender(); + expect(surface).not.toHaveAttribute('data-cover-target'); + rerender(); + expect(surface).not.toHaveAttribute('data-cover-target'); + expect(getByTestId('surface')).toBe(surface); + expect(ref.current).toBe(surface); + + unmount(); + expect(ref.current).toBeNull(); + expect(surface).not.toHaveAttribute('data-cover-target'); + }); + + it('keeps parent and submenu covering states independent', () => { + const Example = ({ parentCovers }: { parentCovers: boolean }) => ( + + + + + + + + + Child + + + + Nested item + + + + + + + ); + const { getByTestId, rerender } = render(); + const parent = getByTestId('parent-surface'); + const child = getByTestId('child-surface'); + + expect(parent).toHaveAttribute('data-cover-target', ''); + expect(child).not.toHaveAttribute('data-cover-target'); + rerender(); + expect(getByTestId('parent-surface')).toBe(parent); + expect(getByTestId('child-surface')).toBe(child); + expect(parent).not.toHaveAttribute('data-cover-target'); + expect(child).toHaveAttribute('data-cover-target', ''); + }); + }); + it('renders trigger and surface children when open', () => { const { getByText } = render( diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.test.tsx index 32eaa678fd8365..52d67a1d54783a 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.test.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.test.tsx @@ -8,6 +8,26 @@ import { PopoverSurface } from './PopoverSurface/PopoverSurface'; import { usePopover } from './usePopover'; describe('Popover', () => { + it('exposes covering state on its positioned surface', () => { + const Example = ({ coverTarget }: { coverTarget: boolean }) => ( + + + + + Content + + ); + const { getByTestId, rerender, unmount } = render(); + const surface = getByTestId('covering-surface'); + expect(surface).toHaveAttribute('data-cover-target', ''); + rerender(); + expect(getByTestId('covering-surface')).toBe(surface); + expect(surface).not.toHaveAttribute('data-cover-target'); + rerender(); + unmount(); + expect(surface).not.toHaveAttribute('data-cover-target'); + }); + describe('close lifecycle', () => { const Example = ({ open, trapFocus = false }: { open: boolean; trapFocus?: boolean }) => ( 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..8c3b704f182b3c 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 @@ -41,6 +41,7 @@ describe('TagPicker', () => { expect(getByRole('combobox')).toBeInTheDocument(); expect(getByRole('listbox')).toBeInTheDocument(); + expect(getByRole('listbox')).not.toHaveAttribute('data-cover-target'); expect(getAllByRole('option')).toHaveLength(3); }); 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..739be7a16cfd27 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,5 +1,6 @@ import * as React from 'react'; import { act, render } from '@testing-library/react'; +import { useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; import { usePositioning } from './usePositioning'; import { getPlacementString } from './utils/placement'; import type { PositioningProps, PositioningReturn } from './types'; @@ -15,7 +16,116 @@ function mountHook(options: PositioningProps = {}) { return resultRef as unknown as { current: PositioningReturn }; } +const PositionedSurface = ({ + options = {}, + version = 0, + attached = true, + marker, +}: { + options?: PositioningProps; + version?: number; + attached?: boolean; + marker?: string; +}) => { + const { containerRef } = usePositioning(options); + return ( +
+ ); +}; + describe('usePositioning', () => { + describe('data-cover-target', () => { + it.each([undefined, false, true])('reflects coverTarget=%s on attachment', coverTarget => { + const result = mountHook({ coverTarget }); + const node = document.createElement('div'); + node.setAttribute('data-cover-target', 'consumer'); + + act(() => result.current.containerRef(node)); + + expect(node.getAttribute('data-cover-target')).toBe(coverTarget ? '' : null); + }); + + it('updates the same container through false, true, false and omitted options', () => { + const { getByTestId, rerender } = render(); + const surface = getByTestId('surface'); + + expect(surface).not.toHaveAttribute('data-cover-target'); + for (const coverTarget of [true, false, true]) { + rerender(); + expect(getByTestId('surface')).toBe(surface); + expect(surface.getAttribute('data-cover-target')).toBe(coverTarget ? '' : null); + } + rerender(); + expect(getByTestId('surface')).toBe(surface); + expect(surface).not.toHaveAttribute('data-cover-target'); + }); + + it.each([false, true])('owns consumer attribute changes while coverTarget stays %s', coverTarget => { + const { getByTestId, rerender } = render( + , + ); + const surface = getByTestId('surface'); + expect(surface.getAttribute('data-cover-target')).toBe(coverTarget ? '' : null); + + rerender(); + expect(surface.getAttribute('data-cover-target')).toBe(coverTarget ? '' : null); + + rerender(); + expect(surface.getAttribute('data-cover-target')).toBe(coverTarget ? '' : null); + }); + + it('cleans retained nodes on replacement and unmount', () => { + const { getByTestId, rerender, unmount } = render(); + const previous = getByTestId('surface'); + rerender(); + const current = getByTestId('surface'); + + expect(current).not.toBe(previous); + expect(previous).not.toHaveAttribute('data-cover-target'); + expect(current).toHaveAttribute('data-cover-target', ''); + + unmount(); + expect(current).not.toHaveAttribute('data-cover-target'); + }); + + it('removes the marker when the ref detaches from a retained container', () => { + const { getByTestId, rerender } = render(); + const surface = getByTestId('surface'); + + rerender(); + expect(getByTestId('surface')).toBe(surface); + expect(surface).not.toHaveAttribute('data-cover-target'); + + rerender(); + expect(surface).toHaveAttribute('data-cover-target', ''); + }); + + it('cleans a directly attached container when the hook unmounts', () => { + const node = document.createElement('div'); + const Capture = () => { + const { containerRef } = usePositioning({ coverTarget: true }); + useIsomorphicLayoutEffect(() => containerRef(node), [containerRef]); + return null; + }; + const { unmount } = render(); + expect(node).toHaveAttribute('data-cover-target', ''); + unmount(); + expect(node).not.toHaveAttribute('data-cover-target'); + }); + + it('preserves the contract through StrictMode effect replay', () => { + const { getByTestId, unmount } = render( + + + , + ); + const surface = getByTestId('surface'); + expect(surface).toHaveAttribute('data-cover-target', ''); + unmount(); + expect(surface).not.toHaveAttribute('data-cover-target'); + }); + }); + it('returns targetRef and containerRef callbacks', () => { const result = mountHook(); 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..10bb26c7384826 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 @@ -20,6 +20,14 @@ const DEFAULT_FLIP = ['flip-block', 'flip-inline', 'flip-block flip-inline']; const EMPTY_FALLBACK_POSITIONS: PositioningShorthandValue[] = []; +const setCoverTargetAttribute = (node: HTMLElement, coverTarget: boolean) => { + if (coverTarget) { + node.setAttribute('data-cover-target', ''); + } else { + node.removeAttribute('data-cover-target'); + } +}; + /** * Reads the current anchor-name property from an element and parses it into an array of names. * Handles comma-separated values and trimming. @@ -56,6 +64,7 @@ export function usePositioning(options: PositioningProps): PositioningReturn { const [triggerEl, setTriggerEl] = React.useState(null); const [containerEl, setContainerEl] = React.useState(null); + const attachedContainerRef = React.useRef(null); const [imperativeTarget, setImperativeTarget] = React.useState(null); const effectiveTarget = imperativeTarget ?? resolveElementRef(customTarget) ?? triggerEl; @@ -69,6 +78,17 @@ export function usePositioning(options: PositioningProps): PositioningReturn { const requestPlacementUpdate = usePlacementObserver(containerEl, effectiveTarget, targetDocument, coverTarget); + // Reconcile every owning commit, including consumer attribute changes with unchanged positioning options. + useIsomorphicLayoutEffect(() => { + if (attachedContainerRef.current) { + setCoverTargetAttribute(attachedContainerRef.current, coverTarget); + } + }); + + useIsomorphicLayoutEffect(() => { + return () => attachedContainerRef.current?.removeAttribute('data-cover-target'); + }, []); + React.useImperativeHandle( positioningRef, () => ({ @@ -115,12 +135,17 @@ export function usePositioning(options: PositioningProps): PositioningReturn { const containerRef: React.RefCallback = React.useCallback( node => { + if (attachedContainerRef.current !== node) { + attachedContainerRef.current?.removeAttribute('data-cover-target'); + } + attachedContainerRef.current = node; setContainerEl(node); if (!node) { return; } + setCoverTargetAttribute(node, coverTarget); node.style.setProperty('position', strategy); node.style.setProperty('inset', 'auto'); node.style.setProperty('margin', '0'); @@ -181,6 +206,7 @@ export function usePositioning(options: PositioningProps): PositioningReturn { align, mainAxis, crossAxis, + coverTarget, coverAlignment, strategy, matchTargetSize, diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningBestPractices.md b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningBestPractices.md index 1a949854c1339b..b62e3ba1f3c5ce 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningBestPractices.md +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningBestPractices.md @@ -1,3 +1,25 @@ # Best practices These examples are intended to document the `positioning` prop used in Fluent UI Headless Components; please refer to component-specific documentation for best practices for a specific component. + +## Styling covering surfaces + +The shared headless positioning hook adds `data-cover-target=""` to the actual positioned container when the resolved `coverTarget` option is `true`. The attribute is absent when the option is omitted or `false`. This applies to components using the shared hook, including `MenuPopover` and `PopoverSurface`, and to public hook/renderer compositions that preserve the positioning ref. + +Use the presence of this attribute to distinguish covering surfaces from ordinary surfaces. `data-placement` describes placement and cannot distinguish the two modes: either mode can request the same placement. + +Scope selectors to your own surface class, for example: + +```css +.menuSurface:where(:not([data-cover-target])) { + /* Ordinary-surface sizing rules belong here. */ +} + +.menuSurface:where([data-cover-target]) { + /* Covering-surface sizing rules belong here. */ +} +``` + +Covering surfaces use the target's center positioning area. Constraints intended for an ordinary surface's available area can therefore constrain a covering surface to the target's size instead. The attribute only exposes positioning state; headless does not apply menu height limits, scrolling rules, row dimensions, or theme styling. + +`data-cover-target` is reserved for headless positioning, not a consumer-controlled prop. Do not set it yourself. Headless normalizes its presence and empty value when the container attaches and after the positioning owner commits, including when consumer attribute props change without changing positioning options. The marker is removed from a container when its positioning ref detaches or the positioning hook unmounts. Each independently positioned surface owns its own marker, including nested menus. From d318859ee8f2c068ba078a9273c5ec0f978cdf0e Mon Sep 17 00:00:00 2001 From: PaulGMardling Date: Mon, 26 Oct 2026 16:59:00 +0100 Subject: [PATCH 2/4] fix(headless): size menus to available space and reveal focused items Default menus to height autosizing and internal overflow scrolling. Compute available dimensions across permitted native placements, preserve covering geometry and consumer overrides, and keep retained focus visible when the viewport shrinks. Add shared size support, browser regressions and API documentation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...-c39cc8af-e3fe-431f-b463-98654880d0c9.json | 2 +- .../library/etc/positioning.api.md | 2 +- .../src/components/Dropdown/useListboxSlot.ts | 2 + .../library/src/components/Menu/Menu.cy.tsx | 124 +++++++++++++ .../library/src/components/Menu/Menu.test.tsx | 29 +++ .../Menu/MenuPopover/useMenuPopover.ts | 11 +- .../library/src/components/Menu/useMenu.ts | 1 + .../PopoverSurface/usePopoverSurface.ts | 2 + .../src/components/Tooltip/useTooltip.ts | 2 + .../library/src/hooks/usePositioning/types.ts | 1 + .../usePositioning/useAvailableSize.test.tsx | 119 ++++++++++++ .../hooks/usePositioning/useAvailableSize.ts | 173 ++++++++++++++++++ .../hooks/usePositioning/usePositioning.ts | 35 +++- .../Positioning/PositioningBestPractices.md | 16 +- 14 files changed, 513 insertions(+), 6 deletions(-) create mode 100644 packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useAvailableSize.test.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useAvailableSize.ts diff --git a/change/@fluentui-react-headless-components-preview-c39cc8af-e3fe-431f-b463-98654880d0c9.json b/change/@fluentui-react-headless-components-preview-c39cc8af-e3fe-431f-b463-98654880d0c9.json index f57c4b7b9ba124..89d6bf4a12cce3 100644 --- a/change/@fluentui-react-headless-components-preview-c39cc8af-e3fe-431f-b463-98654880d0c9.json +++ b/change/@fluentui-react-headless-components-preview-c39cc8af-e3fe-431f-b463-98654880d0c9.json @@ -1,6 +1,6 @@ { "type": "patch", - "comment": "fix: expose resolved coverTarget as a headless positioning data attribute for surface styling", + "comment": "fix: keep ordinary menus within available space and scroll focused items internally while preserving covering menus and native placement fallbacks", "packageName": "@fluentui/react-headless-components-preview", "email": "paulmardling@microsoft.com" } diff --git a/packages/react-components/react-headless-components-preview/library/etc/positioning.api.md b/packages/react-components/react-headless-components-preview/library/etc/positioning.api.md index 8125ce41767032..12c1b8baec888e 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/positioning.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/positioning.api.md @@ -28,7 +28,7 @@ export { Position } export { PositioningImperativeRef } // @public (undocumented) -export type PositioningProps = Pick; +export type PositioningProps = Pick; // @public (undocumented) export type PositioningReturn = { diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Dropdown/useListboxSlot.ts b/packages/react-components/react-headless-components-preview/library/src/components/Dropdown/useListboxSlot.ts index 2fd4d7e12a81ba..ee61daaf5d01e0 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Dropdown/useListboxSlot.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Dropdown/useListboxSlot.ts @@ -16,6 +16,7 @@ import type { ExtractSlotProps, Slot, SlotComponentType } from '@fluentui/react- import type { DropdownState } from './Dropdown.types'; import { Listbox } from './Listbox'; import type { ListboxProps } from './Listbox'; +import { positioningSizeStyles } from '../../hooks/usePositioning/useAvailableSize'; export type UseListboxSlotState = Pick; @@ -96,6 +97,7 @@ export function useListboxSlot( const popoverRef = React.useRef(null); const listboxRef = useMergedRefs(listboxSlot?.ref, ref, popoverRef); if (listboxSlot) { + listboxSlot.style = { ...positioningSizeStyles, ...listboxSlot.style }; listboxSlot.ref = listboxRef; listboxSlot.onMouseDown = onMouseDown; listboxSlot.onClick = onClick; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/Menu.cy.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Menu/Menu.cy.tsx index 487e4acbb51fb2..3e45122b89c622 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Menu/Menu.cy.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/Menu.cy.tsx @@ -25,6 +25,130 @@ describe('Menu', () => { }); }); + ([320, 640] as const).forEach(width => { + (['ltr', 'rtl'] as const).forEach(dir => { + it(`scrolls focused long-menu items internally at ${width}x240 in ${dir}`, () => { + cy.viewport(width, 240); + cy.document().then(doc => doc.documentElement.setAttribute('dir', dir)); + mount( +
+ + + + + + + {Array.from({ length: 40 }, (_, index) => ( + + Item {index + 1} + + ))} + + + +
, + ); + cy.get('[data-testid=long-trigger]').focus().realPress('Enter'); + cy.get('[data-testid=long-surface]').should(surface => { + const rect = surface[0].getBoundingClientRect(); + expect(rect.top).to.be.at.least(0); + expect(rect.bottom).to.be.at.most(240); + expect(surface[0].scrollHeight).to.be.greaterThan(surface[0].clientHeight); + }); + cy.window().then(win => { + const pageScroll = win.scrollY; + for (let index = 1; index < 40; index++) { + cy.realPress('ArrowDown'); + } + cy.get('[data-testid=long-item-40]') + .should('have.focus') + .then(item => { + cy.get('[data-testid=long-surface]').should(surface => { + const itemRect = item[0].getBoundingClientRect(); + const surfaceRect = surface[0].getBoundingClientRect(); + expect(surface[0].scrollTop).to.be.greaterThan(0); + expect(itemRect.top).to.be.at.least(surfaceRect.top); + expect(itemRect.bottom).to.be.at.most(surfaceRect.bottom); + expect(win.scrollY).to.equal(pageScroll); + }); + }); + }); + cy.realPress('Home'); + cy.get('[data-testid=long-item-1]').should('have.focus'); + cy.realPress('End'); + cy.get('[data-testid=long-item-40]').should('have.focus'); + cy.viewport(width, 180); + cy.get('[data-testid=long-item-40]').should(item => { + const rect = item[0].getBoundingClientRect(); + expect(item[0]).to.equal(item[0].ownerDocument.activeElement); + expect(rect.top).to.be.at.least(0); + expect(rect.bottom).to.be.at.most(180); + }); + cy.realPress('Escape'); + cy.get('[data-testid=long-trigger]').should('have.focus').realPress('Space'); + cy.get('[data-testid=long-surface]').should('be.visible'); + }); + }); + }); + + it('preserves native fallback to the larger above area for a long menu', () => { + cy.viewport(320, 240); + mount( +
+ + + + + + + {Array.from({ length: 40 }, (_, index) => ( + Item {index + 1} + ))} + + + +
, + ); + cy.get('[data-testid=fallback-trigger]').focus().realPress('Enter'); + cy.get('[data-testid=fallback-trigger]').then(trigger => { + cy.get('[data-testid=fallback-surface]').should(surface => { + const rect = surface[0].getBoundingClientRect(); + expect(rect.bottom).to.be.at.most(trigger[0].getBoundingClientRect().top); + expect(rect.top).to.be.at.least(0); + expect(rect.height).to.be.greaterThan(100); + expect(surface[0].scrollHeight).to.be.greaterThan(surface[0].clientHeight); + }); + }); + }); + + it('does not size from a fallback that cannot fit the surface width', () => { + cy.viewport(320, 240); + mount( +
+ + + + + + + {Array.from({ length: 40 }, (_, index) => ( + Item {index + 1} + ))} + + + +
, + ); + cy.get('[data-testid=wide-trigger]').focus().realPress('Enter'); + cy.get('[data-testid=wide-surface]').should(surface => { + const rect = surface[0].getBoundingClientRect(); + expect(rect.top).to.be.at.least(0); + expect(rect.bottom).to.be.at.most(240); + expect(rect.height).to.be.greaterThan(50); + expect(surface[0].scrollHeight).to.be.greaterThan(surface[0].clientHeight); + }); + }); + (['ltr', 'rtl'] as const).forEach(dir => { (['above', 'below', 'before', 'after'] as const).forEach(position => { it(`preserves ${position} geometry and offsets in ${dir}`, () => { diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/Menu.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Menu/Menu.test.tsx index a3709c5bb062ea..7231b74624aff4 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Menu/Menu.test.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/Menu.test.tsx @@ -32,6 +32,35 @@ describe('Menu', () => { const MenuComponent = composed ? ComposedMenu : Menu; const Surface = composed ? ComposedMenuPopover : MenuPopover; + it('provides overridable sizing and scrolling defaults without consumer adapters', () => { + const Example = ({ + style, + positioning, + }: { + style?: React.CSSProperties; + positioning?: MenuProps['positioning']; + }) => ( + + + + + + + Item + + + + ); + const { getByTestId, rerender } = render(); + const surface = getByTestId('sized-surface'); + expect(surface.style.overflowY).toBe('auto'); + expect(surface.style.maxHeight).toBe('var(--fui-positioning-available-height, revert-layer)'); + rerender(); + expect(surface.style.getPropertyValue('--fui-positioning-available-height')).toBe('initial'); + expect(surface.style.maxHeight).toBe('120px'); + expect(surface.style.overflowY).toBe('visible'); + }); + it('owns the actual surface marker while preserving props, handlers and refs', () => { const ref = React.createRef(); const onKeyDown = jest.fn(); 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 5edbeb927374ae..6bf0c6dd943d1a 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 @@ -4,6 +4,7 @@ import * as React from 'react'; import { useMenuPopoverBase_unstable } from '@fluentui/react-menu'; import { useMenuContext } from '../menuContext'; import type { MenuPopoverProps, MenuPopoverState } from '@fluentui/react-menu'; +import { positioningSizeStyles } from '../../../hooks/usePositioning/useAvailableSize'; const SUPPORTS_POPOVER_OPEN_SELECTOR = typeof CSS !== 'undefined' && typeof CSS.supports === 'function' && CSS.supports('selector(:popover-open)'); @@ -15,7 +16,15 @@ export const useMenuPopover = (props: MenuPopoverProps, ref: React.Ref ctx.open); 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..fb1d336440e8bd 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 @@ -86,6 +86,7 @@ export const useMenu = (props: MenuProps): MenuState => { const resolvedPositioning = resolvePositioningShorthand(props.positioning); const positioningOptions = { + autoSize: 'height', position: isSubmenu ? 'after' : 'below', align: isSubmenu ? 'top' : 'start', target: openOnContext ? contextTarget : undefined, diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverSurface/usePopoverSurface.ts b/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverSurface/usePopoverSurface.ts index 84faf520ee4948..ec111077612445 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 @@ -5,6 +5,7 @@ import { useMergedRefs, slot, useEventCallback } from '@fluentui/react-utilities import { usePopoverContext } from '../popoverContext'; import { toDataAttributeValue } from '../../../utils'; import type { PopoverSurfaceProps, PopoverSurfaceState } from './PopoverSurface.types'; +import { positioningSizeStyles } from '../../../hooks/usePositioning/useAvailableSize'; /** * Returns the state for a PopoverSurface component. @@ -41,6 +42,7 @@ export const usePopoverSurface = ( ref: useMergedRefs(ref, contentRef, positioningCtx.containerRef), role: trapFocus ? 'dialog' : 'group', ...props, + style: { ...positioningSizeStyles, ...props.style }, id: surfaceId, 'data-popover-surface': '', 'data-open': toDataAttributeValue(open), 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 e52408c658fcd7..47bb72de796fe9 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 @@ -22,6 +22,7 @@ import { KEYBORG_FOCUSIN, useIsNavigatingWithKeyboard } from '@fluentui/react-ta import type { OnVisibleChangeData, TooltipProps, TooltipState, TooltipTriggerProps } from './Tooltip.types'; import { resolvePositioningShorthand, usePositioning } from '../../positioning'; import { toDataAttributeValue } from '../../utils'; +import { positioningSizeStyles } from '../../hooks/usePositioning/useAvailableSize'; /** * Create the state required to render Tooltip. @@ -74,6 +75,7 @@ export const useTooltip = (props: TooltipProps): TooltipState => { const contentRef = useMergedRefs(state.content.ref, containerRef); state.content.ref = contentRef; + state.content.style = { ...positioningSizeStyles, ...state.content.style }; const [setDelayTimeout, clearDelayTimeout] = useTimeout(); diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/types.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/types.ts index 3f92a6d60b922e..b27f6471db7cbf 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/types.ts @@ -13,6 +13,7 @@ export type PositioningReturn = { export type PositioningProps = Pick< CanonicalPositioningProps, + | 'autoSize' | 'align' | 'coverTarget' | 'fallbackPositions' diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useAvailableSize.test.tsx b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useAvailableSize.test.tsx new file mode 100644 index 00000000000000..8035547decc517 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useAvailableSize.test.tsx @@ -0,0 +1,119 @@ +import * as React from 'react'; +import { act, render } from '@testing-library/react'; +import { usePositioning } from './usePositioning'; +import { AVAILABLE_HEIGHT_PROPERTY, AVAILABLE_WIDTH_PROPERTY, positioningSizeStyles } from './useAvailableSize'; +import type { PositioningProps } from './types'; + +const Surface = ({ + options, + version = 0, + style, +}: { + options: PositioningProps; + version?: number; + style?: React.CSSProperties; +}) => { + const { containerRef } = usePositioning(options); + return
; +}; + +describe('positioning available size', () => { + let target: HTMLElement; + let rect: DOMRect; + + beforeEach(() => { + jest.spyOn(document.documentElement, 'clientHeight', 'get').mockReturnValue(600); + jest.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue(800); + rect = { top: 500, bottom: 520, left: 200, right: 280, width: 80, height: 20, x: 200, y: 500, toJSON: () => ({}) }; + target = document.createElement('button'); + target.getBoundingClientRect = () => rect; + }); + + afterEach(() => jest.restoreAllMocks()); + + it('allows native fallback to the largest permitted block area', () => { + const { getByTestId } = render(); + expect(getByTestId('surface').style.getPropertyValue(AVAILABLE_HEIGHT_PROPERTY)).toBe('500px'); + }); + + it('respects pinned placement and explicit fallback candidates', () => { + const { getByTestId, rerender } = render( + , + ); + const surface = getByTestId('surface'); + expect(surface.style.getPropertyValue(AVAILABLE_HEIGHT_PROPERTY)).toBe('80px'); + rerender(); + expect(surface.style.getPropertyValue(AVAILABLE_HEIGHT_PROPERTY)).toBe('100px'); + }); + + it.each(['content-box', 'border-box'] as const)('accounts for padding and borders with %s sizing', boxSizing => { + const { getByTestId } = render( + , + ); + expect(getByTestId('surface').style.getPropertyValue(AVAILABLE_HEIGHT_PROPERTY)).toBe( + boxSizing === 'border-box' ? '500px' : '480px', + ); + }); + + it('updates on viewport resize and target replacement', () => { + const { getByTestId, rerender } = render( + , + ); + const surface = getByTestId('surface'); + rect = { ...rect, top: 300, bottom: 320, y: 300 }; + act(() => window.dispatchEvent(new Event('resize'))); + expect(surface.style.getPropertyValue(AVAILABLE_HEIGHT_PROPERTY)).toBe('280px'); + const replacement = document.createElement('button'); + replacement.getBoundingClientRect = () => ({ ...rect, bottom: 400 }); + rerender(); + expect(surface.style.getPropertyValue(AVAILABLE_HEIGHT_PROPERTY)).toBe('200px'); + }); + + it.each([false, undefined] as const)('blocks inherited sizing when autoSize=%s', autoSize => { + const { getByTestId } = render(); + expect(getByTestId('surface').style.getPropertyValue(AVAILABLE_HEIGHT_PROPERTY)).toBe('initial'); + }); + + it('disables automatic sizing for covering surfaces and restores it when covering is removed', () => { + const { getByTestId, rerender } = render(); + const surface = getByTestId('surface'); + expect(surface.style.getPropertyValue(AVAILABLE_HEIGHT_PROPERTY)).toBe('initial'); + rerender(); + expect(surface.style.getPropertyValue(AVAILABLE_HEIGHT_PROPERTY)).toBe('500px'); + }); + + it.each(['height', 'height-always', 'width', 'width-always', 'always', true] as const)( + 'supports canonical autoSize=%s without constraining disabled axes', + autoSize => { + const { getByTestId } = render(); + const surface = getByTestId('surface'); + expect(surface.style.getPropertyValue(AVAILABLE_HEIGHT_PROPERTY)).toBe( + autoSize === 'width' || autoSize === 'width-always' ? 'initial' : '500px', + ); + expect(surface.style.getPropertyValue(AVAILABLE_WIDTH_PROPERTY)).toBe( + autoSize === 'height' || autoSize === 'height-always' ? 'initial' : '800px', + ); + }, + ); + + it('cleans both reserved properties from replaced and unmounted containers', () => { + const { getByTestId, rerender, unmount } = render(); + const previous = getByTestId('surface'); + rerender(); + expect(previous.style.getPropertyValue(AVAILABLE_HEIGHT_PROPERTY)).toBe(''); + expect(previous.style.getPropertyValue(AVAILABLE_WIDTH_PROPERTY)).toBe(''); + const current = getByTestId('surface'); + unmount(); + expect(current.style.getPropertyValue(AVAILABLE_HEIGHT_PROPERTY)).toBe(''); + expect(current.style.getPropertyValue(AVAILABLE_WIDTH_PROPERTY)).toBe(''); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useAvailableSize.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useAvailableSize.ts new file mode 100644 index 00000000000000..287c0488e2c8bc --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useAvailableSize.ts @@ -0,0 +1,173 @@ +'use client'; + +import { useEventCallback, useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; +import type { PositioningProps } from './types'; + +export const AVAILABLE_HEIGHT_PROPERTY = '--fui-positioning-available-height'; +export const AVAILABLE_WIDTH_PROPERTY = '--fui-positioning-available-width'; + +export const positioningSizeStyles = { + maxHeight: `var(${AVAILABLE_HEIGHT_PROPERTY}, revert-layer)`, + maxWidth: `var(${AVAILABLE_WIDTH_PROPERTY}, revert-layer)`, +}; + +export function useAvailableSize( + container: HTMLElement | null, + target: HTMLElement | null, + targetDocument: Document | undefined, + autoSize: PositioningProps['autoSize'], + areas: readonly string[], +): () => void { + const heightEnabled = + autoSize === true || autoSize === 'always' || autoSize === 'height' || autoSize === 'height-always'; + const widthEnabled = + autoSize === true || autoSize === 'always' || autoSize === 'width' || autoSize === 'width-always'; + const enabled = heightEnabled || widthEnabled; + const update = useEventCallback(() => { + const win = targetDocument?.defaultView; + if (!container || !win) { + return; + } + + // Invalid custom-property values block inheritance and let consumer CSS remain authoritative when disabled. + let height = 'initial'; + let width = 'initial'; + if (enabled && target) { + const viewport = win.visualViewport; + let top = viewport?.offsetTop ?? 0; + let bottom = top + (viewport?.height ?? targetDocument.documentElement.clientHeight); + let left = viewport?.offsetLeft ?? 0; + let right = left + (viewport?.width ?? targetDocument.documentElement.clientWidth); + const targetRect = target.getBoundingClientRect(); + const styles = win.getComputedStyle(container); + const containingBlock = container.offsetParent; + if (styles.position === 'absolute' && containingBlock instanceof win.HTMLElement) { + const rect = containingBlock.getBoundingClientRect(); + top = Math.max(top, rect.top + containingBlock.clientTop); + bottom = Math.min(bottom, rect.top + containingBlock.clientTop + containingBlock.clientHeight); + left = Math.max(left, rect.left + containingBlock.clientLeft); + right = Math.min(right, rect.left + containingBlock.clientLeft + containingBlock.clientWidth); + } + const margins = (parseFloat(styles.marginTop) || 0) + (parseFloat(styles.marginBottom) || 0); + const insets = + styles.boxSizing === 'border-box' + ? 0 + : (parseFloat(styles.paddingTop) || 0) + + (parseFloat(styles.paddingBottom) || 0) + + (parseFloat(styles.borderTopWidth) || 0) + + (parseFloat(styles.borderBottomWidth) || 0); + const rtl = styles.direction === 'rtl'; + const inlineMargins = (parseFloat(styles.marginLeft) || 0) + (parseFloat(styles.marginRight) || 0); + const inlineInsets = + styles.boxSizing === 'border-box' + ? 0 + : (parseFloat(styles.paddingLeft) || 0) + + (parseFloat(styles.paddingRight) || 0) + + (parseFloat(styles.borderLeftWidth) || 0) + + (parseFloat(styles.borderRightWidth) || 0); + const inlineAvailable = areas.map(area => { + if (area.startsWith('inline-start')) { + return rtl ? right - targetRect.right : targetRect.left - left; + } + if (area.startsWith('inline-end')) { + return rtl ? targetRect.left - left : right - targetRect.right; + } + if (area.includes('span-inline-start')) { + return rtl ? right - targetRect.left : targetRect.right - left; + } + if (area.includes('span-inline-end')) { + return rtl ? targetRect.right - left : right - targetRect.left; + } + return right - left; + }); + const available = areas.map(area => { + if (area.startsWith('block-start')) { + return targetRect.top - top; + } + if (area.startsWith('block-end')) { + return bottom - targetRect.bottom; + } + if (area.includes('span-block-start')) { + return targetRect.bottom - top; + } + if (area.includes('span-block-end')) { + return bottom - targetRect.top; + } + return bottom - top; + }); + // A cap based only on the requested area would shrink the menu before native fallbacks can run. + if (heightEnabled) { + const surfaceWidth = container.getBoundingClientRect().width; + const fittingAreas = widthEnabled + ? available + : available.filter((_, index) => surfaceWidth + inlineMargins <= inlineAvailable[index]); + const candidates = fittingAreas.length ? fittingAreas : available; + height = `${Math.max(0, Math.min(bottom - top, Math.max(...candidates)) - margins - insets)}px`; + } + if (widthEnabled) { + const surfaceHeight = container.getBoundingClientRect().height; + const fittingAreas = heightEnabled + ? inlineAvailable + : inlineAvailable.filter((_, index) => surfaceHeight + margins <= available[index]); + const candidates = fittingAreas.length ? fittingAreas : inlineAvailable; + width = `${Math.max(0, Math.min(right - left, Math.max(...candidates)) - inlineMargins - inlineInsets)}px`; + } + } + + const heightChanged = container.style.getPropertyValue(AVAILABLE_HEIGHT_PROPERTY) !== height; + if (heightChanged) { + container.style.setProperty(AVAILABLE_HEIGHT_PROPERTY, height); + } + if (container.style.getPropertyValue(AVAILABLE_WIDTH_PROPERTY) !== width) { + container.style.setProperty(AVAILABLE_WIDTH_PROPERTY, width); + } + + if (heightChanged && heightEnabled) { + const root = container.getRootNode(); + const active = root instanceof win.ShadowRoot ? root.activeElement : targetDocument.activeElement; + if (active instanceof win.HTMLElement && active !== container && active.closest('[popover]') === container) { + const surfaceTop = container.getBoundingClientRect().top + container.clientTop; + const surfaceBottom = surfaceTop + container.clientHeight; + const focusedRect = active.getBoundingClientRect(); + if (focusedRect.bottom > surfaceBottom) { + container.scrollBy({ top: focusedRect.bottom - surfaceBottom, behavior: 'instant' }); + } else if (focusedRect.top < surfaceTop) { + container.scrollBy({ top: focusedRect.top - surfaceTop, behavior: 'instant' }); + } + } + } + }); + + useIsomorphicLayoutEffect(() => { + update(); + }); + + useIsomorphicLayoutEffect(() => { + const win = targetDocument?.defaultView; + if (!container || !win) { + return; + } + + const observer = win.ResizeObserver ? new win.ResizeObserver(update) : undefined; + if (enabled && target) { + observer?.observe(target); + observer?.observe(container); + win.addEventListener('resize', update); + win.addEventListener('scroll', update, { capture: true, passive: true }); + win.visualViewport?.addEventListener('resize', update); + win.visualViewport?.addEventListener('scroll', update); + } + + return () => { + observer?.disconnect(); + win.removeEventListener('resize', update); + win.removeEventListener('scroll', update, { capture: true }); + win.visualViewport?.removeEventListener('resize', update); + win.visualViewport?.removeEventListener('scroll', update); + container.style.removeProperty(AVAILABLE_HEIGHT_PROPERTY); + container.style.removeProperty(AVAILABLE_WIDTH_PROPERTY); + }; + }, [container, target, targetDocument, enabled, update]); + + return update; +} 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 10bb26c7384826..9f4b95fbd3691c 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 @@ -13,6 +13,7 @@ import { POSITIONS, ALIGNMENTS, POSITION_AREA_MAP } from './constants'; import { getPlacementString, normalizeAlign } from './utils/placement'; import { applyOffset, getCoverSelfAlignment, resolveElementRef, resolveOffset, shorthandToPositionArea } from './utils'; import { usePlacementObserver } from './usePlacementObserver'; +import { AVAILABLE_HEIGHT_PROPERTY, AVAILABLE_WIDTH_PROPERTY, useAvailableSize } from './useAvailableSize'; export type TargetElement = HTMLElement | PositioningVirtualElement; @@ -51,6 +52,7 @@ export function usePositioning(options: PositioningProps): PositioningReturn { coverTarget = false, strategy = 'fixed', matchTargetSize, + autoSize, positioningRef, } = options; @@ -75,8 +77,32 @@ export function usePositioning(options: PositioningProps): PositioningReturn { const { targetDocument } = useFluent(); const fallbackAreas = React.useMemo(() => fallbackPositions.map(shorthandToPositionArea), [fallbackPositions]); + const sizingAreas = React.useMemo(() => { + if (pinned) { + return [positionArea]; + } + if (fallbackAreas.length) { + return [positionArea, ...fallbackAreas]; + } + const flippedPosition = + position === 'above' ? 'below' : position === 'below' ? 'above' : position === 'before' ? 'after' : 'before'; + const flippedAlign = align === 'start' ? 'end' : align === 'end' ? 'start' : align; + return [ + positionArea, + POSITION_AREA_MAP[flippedPosition][align], + POSITION_AREA_MAP[position][flippedAlign], + POSITION_AREA_MAP[flippedPosition][flippedAlign], + ]; + }, [pinned, positionArea, fallbackAreas, position, align]); const requestPlacementUpdate = usePlacementObserver(containerEl, effectiveTarget, targetDocument, coverTarget); + const requestSizeUpdate = useAvailableSize( + containerEl, + effectiveTarget, + targetDocument, + coverTarget ? false : autoSize, + sizingAreas, + ); // Reconcile every owning commit, including consumer attribute changes with unchanged positioning options. useIsomorphicLayoutEffect(() => { @@ -95,9 +121,12 @@ export function usePositioning(options: PositioningProps): PositioningReturn { setTarget: (el: TargetElement | null) => { setImperativeTarget(resolveElementRef(el)); }, - updatePosition: requestPlacementUpdate, + updatePosition: () => { + requestSizeUpdate(); + requestPlacementUpdate(); + }, }), - [requestPlacementUpdate], + [requestPlacementUpdate, requestSizeUpdate], ); useIsomorphicLayoutEffect(() => { @@ -137,6 +166,8 @@ export function usePositioning(options: PositioningProps): PositioningReturn { node => { if (attachedContainerRef.current !== node) { attachedContainerRef.current?.removeAttribute('data-cover-target'); + attachedContainerRef.current?.style.removeProperty(AVAILABLE_HEIGHT_PROPERTY); + attachedContainerRef.current?.style.removeProperty(AVAILABLE_WIDTH_PROPERTY); } attachedContainerRef.current = node; setContainerEl(node); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningBestPractices.md b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningBestPractices.md index b62e3ba1f3c5ce..779a42150d27b4 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningBestPractices.md +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningBestPractices.md @@ -2,6 +2,20 @@ These examples are intended to document the `positioning` prop used in Fluent UI Headless Components; please refer to component-specific documentation for best practices for a specific component. +## Available-space sizing + +Headless `Menu` defaults to `positioning.autoSize: 'height'`. Ordinary menus are constrained to the space available among their permitted placements, and `MenuPopover` supplies `overflow-y: auto`. Keyboard navigation can therefore reveal long-menu items by scrolling inside the surface instead of leaving focus outside the viewport. Styled consumers inherit this behavior through the public hooks and renderers; no consumer-side sizing rule or positioning adapter is required. + +The size cap accounts for viewport dimensions, target geometry, offset margins, surface padding and borders. It is refreshed when positioning options, targets, viewport size, or observed element sizes change. Shrinking the available height also reveals a retained focused item by adjusting the surface's scroll position, without scrolling the surrounding page. + +The cap considers the requested placement and permitted fallbacks rather than shrinking immediately to the requested side. This preserves native fallback selection when, for example, a long menu has little room below its trigger but sufficient room above. `pinned` restricts the cap to the requested placement; explicit fallback positions retain their order. + +Covering surfaces remain content-sized: their target's center area is not used as a menu-height limit. Each surface receives its own constraint, so covering submenus do not inherit an ordinary parent's maximum. + +Use `positioning={{ autoSize: false }}` to opt out. `MenuPopover` spreads consumer inline styles after its defaults, so explicit `maxHeight`, `maxWidth`, and `overflowY` remain available as overrides. When automatic sizing is disabled, existing stylesheet maximum sizes remain in effect. + +Other shared positioned surfaces opt in through `autoSize`. The canonical `'height'`, `'width'`, `true` and legacy `*-always` values select the constrained axes. Their hooks supply overridable size styles; only Menu adds scrolling by default. The underlying available-size custom properties are internal implementation details, not a consumer state-transport API. + ## Styling covering surfaces The shared headless positioning hook adds `data-cover-target=""` to the actual positioned container when the resolved `coverTarget` option is `true`. The attribute is absent when the option is omitted or `false`. This applies to components using the shared hook, including `MenuPopover` and `PopoverSurface`, and to public hook/renderer compositions that preserve the positioning ref. @@ -20,6 +34,6 @@ Scope selectors to your own surface class, for example: } ``` -Covering surfaces use the target's center positioning area. Constraints intended for an ordinary surface's available area can therefore constrain a covering surface to the target's size instead. The attribute only exposes positioning state; headless does not apply menu height limits, scrolling rules, row dimensions, or theme styling. +Covering surfaces use the target's center positioning area. Constraints intended for an ordinary surface's available area can therefore constrain a covering surface to the target's size instead. Headless Menu already handles its height constraints and scrolling; this attribute is available for additional consumer styling, not required to obtain that behavior. Row dimensions and theme styling remain the styled consumer's responsibility. `data-cover-target` is reserved for headless positioning, not a consumer-controlled prop. Do not set it yourself. Headless normalizes its presence and empty value when the container attaches and after the positioning owner commits, including when consumer attribute props change without changing positioning options. The marker is removed from a container when its positioning ref detaches or the positioning hook unmounts. Each independently positioned surface owns its own marker, including nested menus. From d396b6b5beb45bd4d86d57c49b2edd4f79341dff Mon Sep 17 00:00:00 2001 From: PaulGMardling Date: Mon, 26 Oct 2026 17:57:18 +0100 Subject: [PATCH 3/4] fix(react-headless-components-preview): reduce sizing bundle cost and align browser tests Share axis calculations and viewport listener setup without changing autosize behavior or bundle thresholds. Run compatibility suites in Chrome with the existing focusgroup polyfill, while retaining explicit native focusgroup coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../library/cypress.config.ts | 19 ++- .../library/src/components/Menu/Menu.cy.tsx | 8 + .../usePositioning/useAvailableSize.test.tsx | 27 +++ .../hooks/usePositioning/useAvailableSize.ts | 158 ++++++++++-------- 4 files changed, 141 insertions(+), 71 deletions(-) diff --git a/packages/react-components/react-headless-components-preview/library/cypress.config.ts b/packages/react-components/react-headless-components-preview/library/cypress.config.ts index ca52cf041bbf2c..bd0df29e871a1a 100644 --- a/packages/react-components/react-headless-components-preview/library/cypress.config.ts +++ b/packages/react-components/react-headless-components-preview/library/cypress.config.ts @@ -1,3 +1,20 @@ import { baseConfig } from '@fluentui/scripts-cypress'; +import { defineConfig } from 'cypress'; -export default baseConfig; +export default defineConfig({ + ...baseConfig, + // The bundled Electron versions in React integration tests predate CSS position-area. + defaultBrowser: 'chrome', + component: { + ...baseConfig.component, + setupNodeEvents(on, config) { + on('before:browser:launch', (browser, launchOptions) => { + // Compatibility tests assert the polyfill's DOM tab stops; native navigation is tested separately. + if (browser.family === 'chromium' && !config.env.nativeFocusgroup) { + launchOptions.args.push('--disable-blink-features=Focusgroup'); + } + return launchOptions; + }); + }, + }, +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/Menu.cy.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Menu/Menu.cy.tsx index 3e45122b89c622..0fb9c327e6d416 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Menu/Menu.cy.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/Menu.cy.tsx @@ -4,10 +4,18 @@ import { Menu, MenuPopover, MenuList, MenuItem, MenuTrigger } from './'; import { Tooltip } from '../Tooltip'; import type { JSXElement } from '@fluentui/react-utilities'; import type { MenuProps } from './Menu.types'; +import { polyfillBodyAndObserve, supportsFocusGroup } from '@microsoft/focusgroup-polyfill'; +polyfillBodyAndObserve(); const mount = (element: JSXElement) => mountBase(element); describe('Menu', () => { + before(() => { + if (Cypress.env('nativeFocusgroup')) { + expect(supportsFocusGroup(), 'native focusgroup support with no polyfill').to.be.true; + } + }); + describe('covering-state styling contract', () => { let originalDir: string | null; beforeEach(() => { diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useAvailableSize.test.tsx b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useAvailableSize.test.tsx index 8035547decc517..f76a34d26bf021 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useAvailableSize.test.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useAvailableSize.test.tsx @@ -105,6 +105,33 @@ describe('positioning available size', () => { }, ); + it.each([ + ['ltr', 'before', 200], + ['ltr', 'after', 520], + ['rtl', 'before', 520], + ['rtl', 'after', 200], + ] as const)('sizes a pinned %s %s surface along the inline axis', (direction, position, expected) => { + const { getByTestId } = render( + , + ); + expect(getByTestId('surface').style.getPropertyValue(AVAILABLE_WIDTH_PROPERTY)).toBe(`${expected}px`); + }); + + it('removes all viewport listeners when the sizing hook unmounts', () => { + const add = jest.spyOn(window, 'addEventListener'); + const remove = jest.spyOn(window, 'removeEventListener'); + const { unmount } = render(); + const resize = add.mock.calls.find( + ([event, , options]) => event === 'resize' && typeof options === 'object' && options.passive, + ); + const scroll = add.mock.calls.find(([event, listener]) => event === 'scroll' && listener === resize?.[1]); + expect(resize).toBeDefined(); + expect(scroll).toBeDefined(); + unmount(); + expect(remove).toHaveBeenCalledWith(...resize!); + expect(remove).toHaveBeenCalledWith(...scroll!); + }); + it('cleans both reserved properties from replaced and unmounted containers', () => { const { getByTestId, rerender, unmount } = render(); const previous = getByTestId('surface'); diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useAvailableSize.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useAvailableSize.ts index 287c0488e2c8bc..01f171a3d73e33 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useAvailableSize.ts +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useAvailableSize.ts @@ -11,6 +11,55 @@ export const positioningSizeStyles = { maxWidth: `var(${AVAILABLE_WIDTH_PROPERTY}, revert-layer)`, }; +function getAvailableSpace( + area: string, + axis: 'block' | 'inline', + start: number, + end: number, + targetStart: number, + targetEnd: number, + reverse = false, +): number { + if (area.startsWith(`${axis}-start`)) { + return reverse ? end - targetEnd : targetStart - start; + } + if (area.startsWith(`${axis}-end`)) { + return reverse ? targetStart - start : end - targetEnd; + } + if (area.includes(`span-${axis}-start`)) { + return reverse ? end - targetStart : targetEnd - start; + } + if (area.includes(`span-${axis}-end`)) { + return reverse ? targetEnd - start : end - targetStart; + } + return end - start; +} + +function getBoxSpacing(styles: CSSStyleDeclaration, sides: readonly string[]): [number, number] { + let margins = 0; + let insets = 0; + for (const side of sides) { + margins += parseFloat(styles.getPropertyValue(`margin-${side}`)) || 0; + if (styles.boxSizing !== 'border-box') { + insets += + (parseFloat(styles.getPropertyValue(`padding-${side}`)) || 0) + + (parseFloat(styles.getPropertyValue(`border-${side}-width`)) || 0); + } + } + return [margins, insets]; +} + +function getMaximumSize( + available: number[], + crossAvailable: number[], + crossSize: number, + extent: number, + spacing: number, +): string { + const fitting = available.filter((_, index) => crossSize <= crossAvailable[index]); + return `${Math.max(0, Math.min(extent, Math.max(...(fitting.length ? fitting : available))) - spacing)}px`; +} + export function useAvailableSize( container: HTMLElement | null, target: HTMLElement | null, @@ -18,10 +67,9 @@ export function useAvailableSize( autoSize: PositioningProps['autoSize'], areas: readonly string[], ): () => void { - const heightEnabled = - autoSize === true || autoSize === 'always' || autoSize === 'height' || autoSize === 'height-always'; - const widthEnabled = - autoSize === true || autoSize === 'always' || autoSize === 'width' || autoSize === 'width-always'; + const bothEnabled = autoSize === true || autoSize === 'always'; + const heightEnabled = bothEnabled || autoSize === 'height' || autoSize === 'height-always'; + const widthEnabled = bothEnabled || autoSize === 'width' || autoSize === 'width-always'; const enabled = heightEnabled || widthEnabled; const update = useEventCallback(() => { const win = targetDocument?.defaultView; @@ -48,69 +96,34 @@ export function useAvailableSize( left = Math.max(left, rect.left + containingBlock.clientLeft); right = Math.min(right, rect.left + containingBlock.clientLeft + containingBlock.clientWidth); } - const margins = (parseFloat(styles.marginTop) || 0) + (parseFloat(styles.marginBottom) || 0); - const insets = - styles.boxSizing === 'border-box' - ? 0 - : (parseFloat(styles.paddingTop) || 0) + - (parseFloat(styles.paddingBottom) || 0) + - (parseFloat(styles.borderTopWidth) || 0) + - (parseFloat(styles.borderBottomWidth) || 0); + const [margins, insets] = getBoxSpacing(styles, ['top', 'bottom']); const rtl = styles.direction === 'rtl'; - const inlineMargins = (parseFloat(styles.marginLeft) || 0) + (parseFloat(styles.marginRight) || 0); - const inlineInsets = - styles.boxSizing === 'border-box' - ? 0 - : (parseFloat(styles.paddingLeft) || 0) + - (parseFloat(styles.paddingRight) || 0) + - (parseFloat(styles.borderLeftWidth) || 0) + - (parseFloat(styles.borderRightWidth) || 0); - const inlineAvailable = areas.map(area => { - if (area.startsWith('inline-start')) { - return rtl ? right - targetRect.right : targetRect.left - left; - } - if (area.startsWith('inline-end')) { - return rtl ? targetRect.left - left : right - targetRect.right; - } - if (area.includes('span-inline-start')) { - return rtl ? right - targetRect.left : targetRect.right - left; - } - if (area.includes('span-inline-end')) { - return rtl ? targetRect.right - left : right - targetRect.left; - } - return right - left; - }); - const available = areas.map(area => { - if (area.startsWith('block-start')) { - return targetRect.top - top; - } - if (area.startsWith('block-end')) { - return bottom - targetRect.bottom; - } - if (area.includes('span-block-start')) { - return targetRect.bottom - top; - } - if (area.includes('span-block-end')) { - return bottom - targetRect.top; - } - return bottom - top; - }); + const [inlineMargins, inlineInsets] = getBoxSpacing(styles, ['left', 'right']); + const surfaceRect = container.getBoundingClientRect(); + const available = areas.map(area => + getAvailableSpace(area, 'block', top, bottom, targetRect.top, targetRect.bottom), + ); + const inlineAvailable = areas.map(area => + getAvailableSpace(area, 'inline', left, right, targetRect.left, targetRect.right, rtl), + ); // A cap based only on the requested area would shrink the menu before native fallbacks can run. if (heightEnabled) { - const surfaceWidth = container.getBoundingClientRect().width; - const fittingAreas = widthEnabled - ? available - : available.filter((_, index) => surfaceWidth + inlineMargins <= inlineAvailable[index]); - const candidates = fittingAreas.length ? fittingAreas : available; - height = `${Math.max(0, Math.min(bottom - top, Math.max(...candidates)) - margins - insets)}px`; + height = getMaximumSize( + available, + inlineAvailable, + widthEnabled ? -Infinity : surfaceRect.width + inlineMargins, + bottom - top, + margins + insets, + ); } if (widthEnabled) { - const surfaceHeight = container.getBoundingClientRect().height; - const fittingAreas = heightEnabled - ? inlineAvailable - : inlineAvailable.filter((_, index) => surfaceHeight + margins <= available[index]); - const candidates = fittingAreas.length ? fittingAreas : inlineAvailable; - width = `${Math.max(0, Math.min(right - left, Math.max(...candidates)) - inlineMargins - inlineInsets)}px`; + width = getMaximumSize( + inlineAvailable, + available, + heightEnabled ? -Infinity : surfaceRect.height + margins, + right - left, + inlineMargins + inlineInsets, + ); } } @@ -149,21 +162,26 @@ export function useAvailableSize( } const observer = win.ResizeObserver ? new win.ResizeObserver(update) : undefined; + const eventTargets = [win, win.visualViewport]; + const events = ['resize', 'scroll']; + const listenerOptions = { capture: true, passive: true }; if (enabled && target) { observer?.observe(target); observer?.observe(container); - win.addEventListener('resize', update); - win.addEventListener('scroll', update, { capture: true, passive: true }); - win.visualViewport?.addEventListener('resize', update); - win.visualViewport?.addEventListener('scroll', update); + for (const eventTarget of eventTargets) { + for (const event of events) { + eventTarget?.addEventListener(event, update, listenerOptions); + } + } } return () => { observer?.disconnect(); - win.removeEventListener('resize', update); - win.removeEventListener('scroll', update, { capture: true }); - win.visualViewport?.removeEventListener('resize', update); - win.visualViewport?.removeEventListener('scroll', update); + for (const eventTarget of eventTargets) { + for (const event of events) { + eventTarget?.removeEventListener(event, update, listenerOptions); + } + } container.style.removeProperty(AVAILABLE_HEIGHT_PROPERTY); container.style.removeProperty(AVAILABLE_WIDTH_PROPERTY); }; From 48859e8eb9626086e68efcde6b07c25c58fd1588 Mon Sep 17 00:00:00 2001 From: PaulGMardling Date: Mon, 26 Oct 2026 19:47:28 +0100 Subject: [PATCH 4/4] fix(react-headless-components-preview): correct autosizing geometry and resize observation Normalize CSS zoom coordinates, select compatible two-axis placement limits, and observe absolute containing-block changes. Consolidate positioning observation and add browser and unit regressions for all three review findings. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../library/src/components/Menu/Menu.test.tsx | 5 +- .../usePositioning/useAvailableSize.cy.tsx | 111 ++++++++ .../usePositioning/useAvailableSize.test.tsx | 101 ++++++- .../hooks/usePositioning/useAvailableSize.ts | 254 ++++++++---------- .../usePositioning/usePlacementObserver.ts | 68 +++-- .../hooks/usePositioning/usePositioning.ts | 74 ++--- .../Positioning/PositioningBestPractices.md | 4 +- 7 files changed, 406 insertions(+), 211 deletions(-) create mode 100644 packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useAvailableSize.cy.tsx diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/Menu.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Menu/Menu.test.tsx index 7231b74624aff4..070460b2c4e87c 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Menu/Menu.test.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/Menu.test.tsx @@ -17,6 +17,7 @@ import { MenuGroupHeader } from './MenuGroupHeader/MenuGroupHeader'; import { MenuSplitGroup } from './MenuSplitGroup/MenuSplitGroup'; import { renderMenu, renderMenuPopover, useMenu, useMenuContextValues, useMenuPopover } from '../../menu'; import type { MenuProps, MenuPopoverProps } from '../../menu'; +import { AVAILABLE_HEIGHT_PROPERTY, positioningSizeStyles } from '../../hooks/usePositioning/useAvailableSize'; const ComposedMenu = (props: MenuProps) => { const state = useMenu(props); @@ -54,9 +55,9 @@ describe('Menu', () => { const { getByTestId, rerender } = render(); const surface = getByTestId('sized-surface'); expect(surface.style.overflowY).toBe('auto'); - expect(surface.style.maxHeight).toBe('var(--fui-positioning-available-height, revert-layer)'); + expect(surface.style.maxHeight).toBe(positioningSizeStyles.maxHeight); rerender(); - expect(surface.style.getPropertyValue('--fui-positioning-available-height')).toBe('initial'); + expect(surface.style.getPropertyValue(AVAILABLE_HEIGHT_PROPERTY)).toBe('initial'); expect(surface.style.maxHeight).toBe('120px'); expect(surface.style.overflowY).toBe('visible'); }); diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useAvailableSize.cy.tsx b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useAvailableSize.cy.tsx new file mode 100644 index 00000000000000..b8889c5b5791ee --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useAvailableSize.cy.tsx @@ -0,0 +1,111 @@ +import * as React from 'react'; +import { mount } from '@fluentui/scripts-cypress'; +import { polyfillBodyAndObserve } from '@microsoft/focusgroup-polyfill'; +import { Menu, MenuTrigger, MenuPopover, MenuList, MenuItem } from '../../components/Menu'; +import { usePositioning } from './usePositioning'; +import { positioningSizeStyles } from './useAvailableSize'; + +polyfillBodyAndObserve(); + +const items = Array.from({ length: 40 }, (_, index) => Item {index + 1}); + +const AbsoluteSurface = () => { + const { targetRef, containerRef } = usePositioning({ + strategy: 'absolute', + autoSize: 'height', + position: 'below', + align: 'start', + pinned: true, + }); + return ( +
+ +
+ Content +
+
+ ); +}; + +describe('positioning size regressions', () => { + it('keeps keyboard focus visible inside a CSS-zoomed menu, including on resize', () => { + cy.viewport(320, 240); + mount( +
+ + + + + + {items} + + +
, + ); + cy.contains('button', 'Trigger').focus().realPress('Enter'); + cy.realPress('End'); + cy.contains('[role="menuitem"]', /^Item 40$/) + .should('have.focus') + .should(item => { + const rect = item[0].getBoundingClientRect(); + expect(rect.top).to.be.at.least(0); + expect(rect.bottom).to.be.at.most(240); + }); + cy.viewport(320, 180); + cy.contains('[role="menuitem"]', /^Item 40$/) + .should('have.focus') + .should(item => { + expect(item[0].getBoundingClientRect().bottom).to.be.at.most(180); + }); + }); + + it('keeps both-axis limits paired with a permitted placement', () => { + cy.viewport(320, 240); + mount( + <> + + + + + + + {items} + + + , + ); + cy.contains('button', 'Trigger').focus().realPress('Enter'); + cy.contains('button', 'Trigger').then(trigger => { + cy.get('[data-testid=surface]').should(surface => { + const rect = surface[0].getBoundingClientRect(); + const target = trigger[0].getBoundingClientRect(); + expect(rect.top >= target.bottom || rect.left >= target.right, 'below or after the target').to.be.true; + expect(rect.top).to.be.at.least(0); + expect(rect.bottom).to.be.at.most(240); + expect(rect.right).to.be.at.most(320); + }); + }); + }); + + it('refreshes an absolute surface when only its containing block shrinks', () => { + cy.viewport(800, 600); + mount(); + cy.get('[data-testid=surface]').should('have.css', 'max-height', '440px'); + cy.get('[data-testid=parent]').invoke('css', 'height', '300px'); + cy.get('[data-testid=surface]') + .should('have.css', 'max-height', '240px') + .should(surface => { + expect(surface[0].getBoundingClientRect().bottom).to.be.at.most( + surface[0].parentElement!.getBoundingClientRect().bottom, + ); + }); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useAvailableSize.test.tsx b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useAvailableSize.test.tsx index f76a34d26bf021..a3d815fb824e45 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useAvailableSize.test.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useAvailableSize.test.tsx @@ -1,7 +1,12 @@ import * as React from 'react'; -import { act, render } from '@testing-library/react'; +import { act, render, waitFor } from '@testing-library/react'; import { usePositioning } from './usePositioning'; -import { AVAILABLE_HEIGHT_PROPERTY, AVAILABLE_WIDTH_PROPERTY, positioningSizeStyles } from './useAvailableSize'; +import { + AVAILABLE_HEIGHT_PROPERTY, + AVAILABLE_WIDTH_PROPERTY, + positioningSizeStyles, + updateAvailableSize, +} from './useAvailableSize'; import type { PositioningProps } from './types'; const Surface = ({ @@ -64,20 +69,102 @@ describe('positioning available size', () => { ); }); - it('updates on viewport resize and target replacement', () => { + it('updates on viewport resize and target replacement', async () => { const { getByTestId, rerender } = render( , ); const surface = getByTestId('surface'); rect = { ...rect, top: 300, bottom: 320, y: 300 }; act(() => window.dispatchEvent(new Event('resize'))); - expect(surface.style.getPropertyValue(AVAILABLE_HEIGHT_PROPERTY)).toBe('280px'); + await waitFor(() => expect(surface.style.getPropertyValue(AVAILABLE_HEIGHT_PROPERTY)).toBe('280px')); const replacement = document.createElement('button'); replacement.getBoundingClientRect = () => ({ ...rect, bottom: 400 }); rerender(); expect(surface.style.getPropertyValue(AVAILABLE_HEIGHT_PROPERTY)).toBe('200px'); }); + it('normalizes cumulative CSS zoom even without currentCSSZoom support', () => { + const parent = document.createElement('div'); + const surface = document.createElement('div'); + parent.style.zoom = '2'; + surface.style.zoom = '1.5'; + const getComputedStyle = window.getComputedStyle.bind(window); + jest.spyOn(window, 'getComputedStyle').mockImplementation(element => { + const styles = getComputedStyle(element); + Object.defineProperty(styles, 'zoom', { value: (element as HTMLElement).style.zoom || '1' }); + return styles; + }); + Object.defineProperty(surface, 'currentCSSZoom', { value: undefined }); + parent.appendChild(surface); + document.body.appendChild(parent); + try { + updateAvailableSize(surface, target, document, 'height', ['block-end']); + expect(parseFloat(surface.style.getPropertyValue(AVAILABLE_HEIGHT_PROPERTY))).toBeCloseTo(80 / 3); + } finally { + parent.remove(); + } + }); + + it.each([ + [700, 1000, '600px', '390px'], + [700, 200, '290px', '800px'], + [100, 350, '600px', '390px'], + ])('chooses a compatible pair for %sx%s content', (width, height, expectedHeight, expectedWidth) => { + rect = { ...rect, top: 290, bottom: 310, left: 390, right: 410 }; + const surface = document.createElement('div'); + surface.getBoundingClientRect = () => ({ ...rect, width, height }); + updateAvailableSize(surface, target, document, true, ['block-end', 'inline-end']); + expect(surface.style.getPropertyValue(AVAILABLE_HEIGHT_PROPERTY)).toBe(expectedHeight); + expect(surface.style.getPropertyValue(AVAILABLE_WIDTH_PROPERTY)).toBe(expectedWidth); + }); + + it('rebinds containing-block observation and cancels queued updates on cleanup', () => { + const observe = jest.fn(); + const unobserve = jest.fn(); + const disconnect = jest.fn(); + const observer = { observe, unobserve, disconnect }; + let notify: () => void = jest.fn(); + jest.spyOn(window, 'ResizeObserver').mockImplementation(callback => { + notify = () => callback([], observer); + return observer; + }); + let parent = document.createElement('div'); + jest.spyOn(HTMLElement.prototype, 'offsetParent', 'get').mockImplementation(() => parent); + const { rerender, unmount } = render(); + expect(observe).toHaveBeenCalledWith(parent); + const previous = parent; + parent = document.createElement('div'); + rerender(); + expect(unobserve).toHaveBeenCalledWith(previous); + expect(observe).toHaveBeenCalledWith(parent); + jest.spyOn(window, 'requestAnimationFrame').mockReturnValue(42); + const cancel = jest.spyOn(window, 'cancelAnimationFrame'); + notify(); + unmount(); + expect(disconnect).toHaveBeenCalled(); + expect(cancel).toHaveBeenCalledWith(42); + }); + + it('does not reuse a disconnected observer after the target changes', () => { + const observers: Array<{ observe: jest.Mock; unobserve: jest.Mock; disconnect: jest.Mock }> = []; + jest.spyOn(window, 'ResizeObserver').mockImplementation(() => { + const observer = { observe: jest.fn(), unobserve: jest.fn(), disconnect: jest.fn() }; + observers.push(observer); + return observer; + }); + const parent = document.createElement('div'); + jest.spyOn(HTMLElement.prototype, 'offsetParent', 'get').mockReturnValue(parent); + const { rerender } = render(); + const previous = observers[0]; + const calls = previous.observe.mock.calls.length; + rerender( + , + ); + expect(previous.disconnect).toHaveBeenCalledTimes(1); + expect(previous.observe).toHaveBeenCalledTimes(calls); + expect(observers[1].observe).toHaveBeenCalledWith(parent); + }); + it.each([false, undefined] as const)('blocks inherited sizing when autoSize=%s', autoSize => { const { getByTestId } = render(); expect(getByTestId('surface').style.getPropertyValue(AVAILABLE_HEIGHT_PROPERTY)).toBe('initial'); @@ -97,7 +184,11 @@ describe('positioning available size', () => { const { getByTestId } = render(); const surface = getByTestId('surface'); expect(surface.style.getPropertyValue(AVAILABLE_HEIGHT_PROPERTY)).toBe( - autoSize === 'width' || autoSize === 'width-always' ? 'initial' : '500px', + autoSize === 'width' || autoSize === 'width-always' + ? 'initial' + : autoSize === true || autoSize === 'always' + ? '80px' + : '500px', ); expect(surface.style.getPropertyValue(AVAILABLE_WIDTH_PROPERTY)).toBe( autoSize === 'height' || autoSize === 'height-always' ? 'initial' : '800px', diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useAvailableSize.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useAvailableSize.ts index 01f171a3d73e33..1d7223bf66348c 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useAvailableSize.ts +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useAvailableSize.ts @@ -1,16 +1,29 @@ -'use client'; - -import { useEventCallback, useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; import type { PositioningProps } from './types'; -export const AVAILABLE_HEIGHT_PROPERTY = '--fui-positioning-available-height'; -export const AVAILABLE_WIDTH_PROPERTY = '--fui-positioning-available-width'; +export const AVAILABLE_HEIGHT_PROPERTY = '--fui-max-height'; +export const AVAILABLE_WIDTH_PROPERTY = '--fui-max-width'; export const positioningSizeStyles = { maxHeight: `var(${AVAILABLE_HEIGHT_PROPERTY}, revert-layer)`, maxWidth: `var(${AVAILABLE_WIDTH_PROPERTY}, revert-layer)`, }; +function getZoom(element: Element, win: Window): number { + const current = element.currentCSSZoom; + if (current !== undefined) { + return current; + } + let zoom = 1; + for ( + let parent: Element | null | undefined = element; + parent; + parent = parent.parentElement ?? (parent.getRootNode() as ShadowRoot).host + ) { + zoom *= parseFloat(win.getComputedStyle(parent).zoom) || 1; + } + return zoom; +} + function getAvailableSpace( area: string, axis: 'block' | 'inline', @@ -20,19 +33,12 @@ function getAvailableSpace( targetEnd: number, reverse = false, ): number { - if (area.startsWith(`${axis}-start`)) { - return reverse ? end - targetEnd : targetStart - start; - } - if (area.startsWith(`${axis}-end`)) { - return reverse ? targetStart - start : end - targetEnd; - } - if (area.includes(`span-${axis}-start`)) { - return reverse ? end - targetStart : targetEnd - start; - } - if (area.includes(`span-${axis}-end`)) { - return reverse ? targetEnd - start : end - targetStart; + if (!area.includes(axis)) { + return end - start; } - return end - start; + const fromStart = area.includes(`${axis}-start`) !== reverse; + const span = area.includes(`span-${axis}`); + return fromStart ? (span ? targetEnd : targetStart) - start : end - (span ? targetStart : targetEnd); } function getBoxSpacing(styles: CSSStyleDeclaration, sides: readonly string[]): [number, number] { @@ -49,143 +55,109 @@ function getBoxSpacing(styles: CSSStyleDeclaration, sides: readonly string[]): [ return [margins, insets]; } -function getMaximumSize( - available: number[], - crossAvailable: number[], - crossSize: number, - extent: number, - spacing: number, -): string { - const fitting = available.filter((_, index) => crossSize <= crossAvailable[index]); - return `${Math.max(0, Math.min(extent, Math.max(...(fitting.length ? fitting : available))) - spacing)}px`; -} - -export function useAvailableSize( +export function updateAvailableSize( container: HTMLElement | null, target: HTMLElement | null, targetDocument: Document | undefined, autoSize: PositioningProps['autoSize'], areas: readonly string[], -): () => void { - const bothEnabled = autoSize === true || autoSize === 'always'; - const heightEnabled = bothEnabled || autoSize === 'height' || autoSize === 'height-always'; - const widthEnabled = bothEnabled || autoSize === 'width' || autoSize === 'width-always'; - const enabled = heightEnabled || widthEnabled; - const update = useEventCallback(() => { - const win = targetDocument?.defaultView; - if (!container || !win) { - return; - } +): Element | null { + const mode = typeof autoSize === 'string' ? autoSize.split('-')[0] : autoSize; + const heightEnabled = mode && mode !== 'width'; + const widthEnabled = mode && mode !== 'height'; + const bothEnabled = heightEnabled && widthEnabled; + const win = targetDocument?.defaultView; + if (!container || !win) { + return null; + } - // Invalid custom-property values block inheritance and let consumer CSS remain authoritative when disabled. - let height = 'initial'; - let width = 'initial'; - if (enabled && target) { - const viewport = win.visualViewport; - let top = viewport?.offsetTop ?? 0; - let bottom = top + (viewport?.height ?? targetDocument.documentElement.clientHeight); - let left = viewport?.offsetLeft ?? 0; - let right = left + (viewport?.width ?? targetDocument.documentElement.clientWidth); - const targetRect = target.getBoundingClientRect(); - const styles = win.getComputedStyle(container); - const containingBlock = container.offsetParent; - if (styles.position === 'absolute' && containingBlock instanceof win.HTMLElement) { - const rect = containingBlock.getBoundingClientRect(); - top = Math.max(top, rect.top + containingBlock.clientTop); - bottom = Math.min(bottom, rect.top + containingBlock.clientTop + containingBlock.clientHeight); - left = Math.max(left, rect.left + containingBlock.clientLeft); - right = Math.min(right, rect.left + containingBlock.clientLeft + containingBlock.clientWidth); - } - const [margins, insets] = getBoxSpacing(styles, ['top', 'bottom']); - const rtl = styles.direction === 'rtl'; - const [inlineMargins, inlineInsets] = getBoxSpacing(styles, ['left', 'right']); - const surfaceRect = container.getBoundingClientRect(); - const available = areas.map(area => - getAvailableSpace(area, 'block', top, bottom, targetRect.top, targetRect.bottom), + // Invalid custom-property values block inheritance and let consumer CSS remain authoritative when disabled. + let limits = ['initial', 'initial']; + let containingBlock: Element | null = null; + const zoom = getZoom(container, win); + if (autoSize && target) { + const viewport = win.visualViewport; + let top = viewport?.offsetTop ?? 0; + let bottom = top + (viewport?.height ?? targetDocument.documentElement.clientHeight); + let left = viewport?.offsetLeft ?? 0; + let right = left + (viewport?.width ?? targetDocument.documentElement.clientWidth); + const targetRect = target.getBoundingClientRect(); + const styles = win.getComputedStyle(container); + containingBlock = styles.position === 'absolute' ? container.offsetParent : null; + if (containingBlock instanceof win.HTMLElement) { + const rect = containingBlock.getBoundingClientRect(); + const parentZoom = getZoom(containingBlock, win); + top = Math.max(top, rect.top + containingBlock.clientTop * parentZoom); + bottom = Math.min(bottom, rect.top + (containingBlock.clientTop + containingBlock.clientHeight) * parentZoom); + left = Math.max(left, rect.left + containingBlock.clientLeft * parentZoom); + right = Math.min(right, rect.left + (containingBlock.clientLeft + containingBlock.clientWidth) * parentZoom); + } + const [margins, insets] = getBoxSpacing(styles, ['top', 'bottom']); + const rtl = styles.direction === 'rtl'; + const [inlineMargins, inlineInsets] = getBoxSpacing(styles, ['left', 'right']); + const surfaceRect = container.getBoundingClientRect(); + let candidates = areas.map(area => [ + Math.max( + 0, + Math.min(bottom - top, getAvailableSpace(area, 'block', top, bottom, targetRect.top, targetRect.bottom)) / + zoom - + margins - + insets, + ), + Math.max( + 0, + Math.min(right - left, getAvailableSpace(area, 'inline', left, right, targetRect.left, targetRect.right, rtl)) / + zoom - + inlineMargins - + inlineInsets, + ), + ]); + const sizes = [surfaceRect.height / zoom - insets, surfaceRect.width / zoom - inlineInsets]; + if (bothEnabled) { + const demandHeight = Math.max( + sizes[0], + container.scrollHeight + container.offsetHeight - container.clientHeight - insets, ); - const inlineAvailable = areas.map(area => - getAvailableSpace(area, 'inline', left, right, targetRect.left, targetRect.right, rtl), + const demandWidth = Math.max( + sizes[1], + container.scrollWidth + container.offsetWidth - container.clientWidth - inlineInsets, ); - // A cap based only on the requested area would shrink the menu before native fallbacks can run. - if (heightEnabled) { - height = getMaximumSize( - available, - inlineAvailable, - widthEnabled ? -Infinity : surfaceRect.width + inlineMargins, - bottom - top, - margins + insets, - ); - } - if (widthEnabled) { - width = getMaximumSize( - inlineAvailable, - available, - heightEnabled ? -Infinity : surfaceRect.height + margins, - right - left, - inlineMargins + inlineInsets, - ); - } - } - - const heightChanged = container.style.getPropertyValue(AVAILABLE_HEIGHT_PROPERTY) !== height; - if (heightChanged) { - container.style.setProperty(AVAILABLE_HEIGHT_PROPERTY, height); - } - if (container.style.getPropertyValue(AVAILABLE_WIDTH_PROPERTY) !== width) { - container.style.setProperty(AVAILABLE_WIDTH_PROPERTY, width); + const fitting = candidates.findIndex(([height, width]) => height >= demandHeight && width >= demandWidth); + const scores = candidates.map(([height, width]) => Math.min(demandHeight, height) * Math.min(demandWidth, width)); + const best = fitting < 0 ? scores.indexOf(Math.max(...scores)) : fitting; + candidates = [candidates[best]]; } + // A cap based only on the requested area would shrink the menu before native fallbacks can run. + limits = [heightEnabled, widthEnabled].map((enabledAxis, axis) => { + const fitting = candidates.filter(size => bothEnabled || sizes[1 - axis] <= size[1 - axis]); + const maximum = Math.max(...(fitting.length ? fitting : candidates).map(size => size[axis])); + return enabledAxis ? `${maximum}px` : 'initial'; + }); + } - if (heightChanged && heightEnabled) { - const root = container.getRootNode(); - const active = root instanceof win.ShadowRoot ? root.activeElement : targetDocument.activeElement; - if (active instanceof win.HTMLElement && active !== container && active.closest('[popover]') === container) { - const surfaceTop = container.getBoundingClientRect().top + container.clientTop; - const surfaceBottom = surfaceTop + container.clientHeight; - const focusedRect = active.getBoundingClientRect(); - if (focusedRect.bottom > surfaceBottom) { - container.scrollBy({ top: focusedRect.bottom - surfaceBottom, behavior: 'instant' }); - } else if (focusedRect.top < surfaceTop) { - container.scrollBy({ top: focusedRect.top - surfaceTop, behavior: 'instant' }); - } - } + const style = container.style; + const heightChanged = style.getPropertyValue(AVAILABLE_HEIGHT_PROPERTY) !== limits[0]; + [AVAILABLE_HEIGHT_PROPERTY, AVAILABLE_WIDTH_PROPERTY].forEach((property, axis) => { + if (style.getPropertyValue(property) !== limits[axis]) { + style.setProperty(property, limits[axis]); } }); - useIsomorphicLayoutEffect(() => { - update(); - }); - - useIsomorphicLayoutEffect(() => { - const win = targetDocument?.defaultView; - if (!container || !win) { - return; - } - - const observer = win.ResizeObserver ? new win.ResizeObserver(update) : undefined; - const eventTargets = [win, win.visualViewport]; - const events = ['resize', 'scroll']; - const listenerOptions = { capture: true, passive: true }; - if (enabled && target) { - observer?.observe(target); - observer?.observe(container); - for (const eventTarget of eventTargets) { - for (const event of events) { - eventTarget?.addEventListener(event, update, listenerOptions); - } + if (heightChanged && heightEnabled) { + const root = container.getRootNode(); + const active = root instanceof win.ShadowRoot ? root.activeElement : targetDocument.activeElement; + if (active instanceof win.HTMLElement && active !== container && active.closest('[popover]') === container) { + const surfaceTop = container.getBoundingClientRect().top + container.clientTop * zoom; + const surfaceBottom = surfaceTop + container.clientHeight * zoom; + const focusedRect = active.getBoundingClientRect(); + const delta = + focusedRect.bottom > surfaceBottom + ? focusedRect.bottom - surfaceBottom + : Math.min(0, focusedRect.top - surfaceTop); + if (delta) { + container.scrollBy({ top: delta / zoom, behavior: 'instant' }); } } - - return () => { - observer?.disconnect(); - for (const eventTarget of eventTargets) { - for (const event of events) { - eventTarget?.removeEventListener(event, update, listenerOptions); - } - } - container.style.removeProperty(AVAILABLE_HEIGHT_PROPERTY); - container.style.removeProperty(AVAILABLE_WIDTH_PROPERTY); - }; - }, [container, target, targetDocument, enabled, update]); - - return update; + } + return containingBlock; } diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePlacementObserver.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePlacementObserver.ts index c58fed88c50562..5e19bd25b5ee6c 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePlacementObserver.ts +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePlacementObserver.ts @@ -1,7 +1,8 @@ 'use client'; import { useIsomorphicLayoutEffect, useEventCallback } from '@fluentui/react-utilities'; -import { computePosition, debounce } from './utils'; +import * as React from 'react'; +import { computePosition } from './utils'; /** * Mirrors the placement that the browser actually resolves for an @@ -15,15 +16,27 @@ export function usePlacementObserver( containerEl: HTMLElement | null, targetEl: HTMLElement | null, targetDocument: Document | undefined, - disabled = false, + disabled: boolean, + updateSize: () => Element | null, ): () => void { + const observed = React.useRef<{ observer?: ResizeObserver; parent: Element | null }>({ parent: null }); const update = useEventCallback(() => { - if (!containerEl || !targetEl) { + const parent = updateSize(); + if (!containerEl || !targetEl || disabled) { return; } + if (parent !== observed.current.parent) { + if (observed.current.parent) { + observed.current.observer?.unobserve(observed.current.parent); + } + if (parent) { + observed.current.observer?.observe(parent); + } + observed.current.parent = parent; + } const result = computePosition(targetEl, containerEl); - if (!result) { + if (!result || !containerEl.offsetHeight) { return; } @@ -33,39 +46,44 @@ export function usePlacementObserver( }); useIsomorphicLayoutEffect(() => { - if (disabled || !containerEl || !targetEl) { - return; - } + update(); + }); + useIsomorphicLayoutEffect(() => { const win = targetDocument?.defaultView; - if (!win) { + if (disabled || !containerEl || !targetEl || !win) { return; } - const debouncedUpdate = debounce(update); - - const ResizeObserverCtor = win.ResizeObserver; - const resizeObserver = ResizeObserverCtor - ? new ResizeObserverCtor(entries => { - const allLaidOut = entries.every(entry => entry.contentRect.width > 0 && entry.contentRect.height > 0); - if (allLaidOut) { - debouncedUpdate(); - } - }) - : null; + let frame = 0; + const scheduleUpdate = () => { + win.cancelAnimationFrame(frame); + frame = win.requestAnimationFrame(update); + }; + const resizeObserver = win.ResizeObserver ? new win.ResizeObserver(scheduleUpdate) : undefined; + observed.current = { observer: resizeObserver, parent: null }; resizeObserver?.observe(containerEl); resizeObserver?.observe(targetEl); - win.addEventListener('scroll', debouncedUpdate, { capture: true, passive: true }); - win.addEventListener('resize', debouncedUpdate); - - debouncedUpdate(); + const eventTargets = [win, win.visualViewport]; + const events = ['resize', 'scroll']; + const options = { capture: true, passive: true }; + const listen = (method: 'addEventListener' | 'removeEventListener') => { + for (const eventTarget of eventTargets) { + for (const event of events) { + eventTarget?.[method](event, scheduleUpdate, options); + } + } + }; + listen('addEventListener'); + update(); return () => { resizeObserver?.disconnect(); - win.removeEventListener('scroll', debouncedUpdate, { capture: true }); - win.removeEventListener('resize', debouncedUpdate); + win.cancelAnimationFrame(frame); + observed.current = { parent: null }; + listen('removeEventListener'); }; }, [containerEl, targetEl, targetDocument, disabled, update]); 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 9f4b95fbd3691c..5f24b66c8a9740 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 @@ -13,19 +13,27 @@ import { POSITIONS, ALIGNMENTS, POSITION_AREA_MAP } from './constants'; import { getPlacementString, normalizeAlign } from './utils/placement'; import { applyOffset, getCoverSelfAlignment, resolveElementRef, resolveOffset, shorthandToPositionArea } from './utils'; import { usePlacementObserver } from './usePlacementObserver'; -import { AVAILABLE_HEIGHT_PROPERTY, AVAILABLE_WIDTH_PROPERTY, useAvailableSize } from './useAvailableSize'; +import { AVAILABLE_HEIGHT_PROPERTY, AVAILABLE_WIDTH_PROPERTY, updateAvailableSize } from './useAvailableSize'; export type TargetElement = HTMLElement | PositioningVirtualElement; const DEFAULT_FLIP = ['flip-block', 'flip-inline', 'flip-block flip-inline']; const EMPTY_FALLBACK_POSITIONS: PositioningShorthandValue[] = []; +const COVER_TARGET_ATTRIBUTE = 'data-cover-target'; const setCoverTargetAttribute = (node: HTMLElement, coverTarget: boolean) => { if (coverTarget) { - node.setAttribute('data-cover-target', ''); + node.setAttribute(COVER_TARGET_ATTRIBUTE, ''); } else { - node.removeAttribute('data-cover-target'); + node.removeAttribute(COVER_TARGET_ATTRIBUTE); + } +}; + +const clearContainerAttributes = (node: HTMLElement | null) => { + node?.removeAttribute(COVER_TARGET_ATTRIBUTE); + for (const property of [AVAILABLE_HEIGHT_PROPERTY, AVAILABLE_WIDTH_PROPERTY]) { + node?.style.removeProperty(property); } }; @@ -77,31 +85,28 @@ export function usePositioning(options: PositioningProps): PositioningReturn { const { targetDocument } = useFluent(); const fallbackAreas = React.useMemo(() => fallbackPositions.map(shorthandToPositionArea), [fallbackPositions]); - const sizingAreas = React.useMemo(() => { - if (pinned) { - return [positionArea]; - } - if (fallbackAreas.length) { - return [positionArea, ...fallbackAreas]; - } - const flippedPosition = - position === 'above' ? 'below' : position === 'below' ? 'above' : position === 'before' ? 'after' : 'before'; - const flippedAlign = align === 'start' ? 'end' : align === 'end' ? 'start' : align; - return [ - positionArea, - POSITION_AREA_MAP[flippedPosition][align], - POSITION_AREA_MAP[position][flippedAlign], - POSITION_AREA_MAP[flippedPosition][flippedAlign], - ]; - }, [pinned, positionArea, fallbackAreas, position, align]); - - const requestPlacementUpdate = usePlacementObserver(containerEl, effectiveTarget, targetDocument, coverTarget); - const requestSizeUpdate = useAvailableSize( - containerEl, - effectiveTarget, - targetDocument, - coverTarget ? false : autoSize, - sizingAreas, + const flippedPosition = + position === 'above' ? 'below' : position === 'below' ? 'above' : position === 'before' ? 'after' : 'before'; + const flippedAlign = align === 'start' ? 'end' : align === 'end' ? 'start' : align; + const sizingAreas = pinned + ? [positionArea] + : fallbackAreas.length + ? [positionArea, ...fallbackAreas] + : [ + positionArea, + POSITION_AREA_MAP[flippedPosition][align], + POSITION_AREA_MAP[position][flippedAlign], + POSITION_AREA_MAP[flippedPosition][flippedAlign], + ]; + + const requestPlacementUpdate = usePlacementObserver(containerEl, effectiveTarget, targetDocument, coverTarget, () => + updateAvailableSize( + attachedContainerRef.current, + effectiveTarget, + targetDocument, + coverTarget ? false : autoSize, + sizingAreas, + ), ); // Reconcile every owning commit, including consumer attribute changes with unchanged positioning options. @@ -112,7 +117,7 @@ export function usePositioning(options: PositioningProps): PositioningReturn { }); useIsomorphicLayoutEffect(() => { - return () => attachedContainerRef.current?.removeAttribute('data-cover-target'); + return () => clearContainerAttributes(attachedContainerRef.current); }, []); React.useImperativeHandle( @@ -121,12 +126,9 @@ export function usePositioning(options: PositioningProps): PositioningReturn { setTarget: (el: TargetElement | null) => { setImperativeTarget(resolveElementRef(el)); }, - updatePosition: () => { - requestSizeUpdate(); - requestPlacementUpdate(); - }, + updatePosition: requestPlacementUpdate, }), - [requestPlacementUpdate, requestSizeUpdate], + [requestPlacementUpdate], ); useIsomorphicLayoutEffect(() => { @@ -165,9 +167,7 @@ export function usePositioning(options: PositioningProps): PositioningReturn { const containerRef: React.RefCallback = React.useCallback( node => { if (attachedContainerRef.current !== node) { - attachedContainerRef.current?.removeAttribute('data-cover-target'); - attachedContainerRef.current?.style.removeProperty(AVAILABLE_HEIGHT_PROPERTY); - attachedContainerRef.current?.style.removeProperty(AVAILABLE_WIDTH_PROPERTY); + clearContainerAttributes(attachedContainerRef.current); } attachedContainerRef.current = node; setContainerEl(node); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningBestPractices.md b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningBestPractices.md index 779a42150d27b4..c202f6c4f22e2c 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningBestPractices.md +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningBestPractices.md @@ -6,10 +6,12 @@ These examples are intended to document the `positioning` prop used in Fluent UI Headless `Menu` defaults to `positioning.autoSize: 'height'`. Ordinary menus are constrained to the space available among their permitted placements, and `MenuPopover` supplies `overflow-y: auto`. Keyboard navigation can therefore reveal long-menu items by scrolling inside the surface instead of leaving focus outside the viewport. Styled consumers inherit this behavior through the public hooks and renderers; no consumer-side sizing rule or positioning adapter is required. -The size cap accounts for viewport dimensions, target geometry, offset margins, surface padding and borders. It is refreshed when positioning options, targets, viewport size, or observed element sizes change. Shrinking the available height also reveals a retained focused item by adjusting the surface's scroll position, without scrolling the surrounding page. +The size cap accounts for viewport dimensions, target geometry, effective CSS zoom, offset margins, surface padding and borders. It is refreshed when positioning options, targets, viewport size, or observed element sizes change, including an absolute surface's containing block. Shrinking the available height also reveals a retained focused item by adjusting the surface's scroll position, without scrolling the surrounding page. The cap considers the requested placement and permitted fallbacks rather than shrinking immediately to the requested side. This preserves native fallback selection when, for example, a long menu has little room below its trigger but sufficient room above. `pinned` restricts the cap to the requested placement; explicit fallback positions retain their order. +When both axes are constrained, width and height come from the same permitted placement. The first placement that fits the content is preferred; if none fits, the limits preserve the most visible content area, with earlier placements winning ties. + Covering surfaces remain content-sized: their target's center area is not used as a menu-height limit. Each surface receives its own constraint, so covering submenus do not inherit an ordinary parent's maximum. Use `positioning={{ autoSize: false }}` to opt out. `MenuPopover` spreads consumer inline styles after its defaults, so explicit `maxHeight`, `maxWidth`, and `overflowY` remain available as overrides. When automatic sizing is disabled, existing stylesheet maximum sizes remain in effect.