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..89d6bf4a12cce3 --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-c39cc8af-e3fe-431f-b463-98654880d0c9.json @@ -0,0 +1,6 @@ +{ + "type": "patch", + "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/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/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 1fb3793edfbd73..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 @@ -3,10 +3,264 @@ 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'; +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(() => { + 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); + } + }); + }); + + ([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}`, () => { + 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..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 @@ -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,143 @@ 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'; +import { AVAILABLE_HEIGHT_PROPERTY, positioningSizeStyles } from '../../hooks/usePositioning/useAvailableSize'; + +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('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(positioningSizeStyles.maxHeight); + rerender(); + expect(surface.style.getPropertyValue(AVAILABLE_HEIGHT_PROPERTY)).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(); + 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/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/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/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/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/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.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 new file mode 100644 index 00000000000000..a3d815fb824e45 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useAvailableSize.test.tsx @@ -0,0 +1,237 @@ +import * as React from 'react'; +import { act, render, waitFor } from '@testing-library/react'; +import { usePositioning } from './usePositioning'; +import { + AVAILABLE_HEIGHT_PROPERTY, + AVAILABLE_WIDTH_PROPERTY, + positioningSizeStyles, + updateAvailableSize, +} 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', async () => { + const { getByTestId, rerender } = render( + , + ); + const surface = getByTestId('surface'); + rect = { ...rect, top: 300, bottom: 320, y: 300 }; + act(() => window.dispatchEvent(new Event('resize'))); + 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'); + }); + + 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' + : autoSize === true || autoSize === 'always' + ? '80px' + : '500px', + ); + expect(surface.style.getPropertyValue(AVAILABLE_WIDTH_PROPERTY)).toBe( + autoSize === 'height' || autoSize === 'height-always' ? 'initial' : '800px', + ); + }, + ); + + 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'); + 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..1d7223bf66348c --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useAvailableSize.ts @@ -0,0 +1,163 @@ +import type { PositioningProps } from './types'; + +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', + start: number, + end: number, + targetStart: number, + targetEnd: number, + reverse = false, +): number { + if (!area.includes(axis)) { + 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] { + 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]; +} + +export function updateAvailableSize( + container: HTMLElement | null, + target: HTMLElement | null, + targetDocument: Document | undefined, + autoSize: PositioningProps['autoSize'], + areas: readonly string[], +): 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 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 demandWidth = Math.max( + sizes[1], + container.scrollWidth + container.offsetWidth - container.clientWidth - inlineInsets, + ); + 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'; + }); + } + + 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]); + } + }); + + 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 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.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..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,12 +13,29 @@ 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, 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(COVER_TARGET_ATTRIBUTE, ''); + } else { + 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); + } +}; /** * Reads the current anchor-name property from an element and parses it into an array of names. @@ -43,6 +60,7 @@ export function usePositioning(options: PositioningProps): PositioningReturn { coverTarget = false, strategy = 'fixed', matchTargetSize, + autoSize, positioningRef, } = options; @@ -56,6 +74,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; @@ -66,8 +85,40 @@ export function usePositioning(options: PositioningProps): PositioningReturn { const { targetDocument } = useFluent(); const fallbackAreas = React.useMemo(() => fallbackPositions.map(shorthandToPositionArea), [fallbackPositions]); + 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. + useIsomorphicLayoutEffect(() => { + if (attachedContainerRef.current) { + setCoverTargetAttribute(attachedContainerRef.current, coverTarget); + } + }); - const requestPlacementUpdate = usePlacementObserver(containerEl, effectiveTarget, targetDocument, coverTarget); + useIsomorphicLayoutEffect(() => { + return () => clearContainerAttributes(attachedContainerRef.current); + }, []); React.useImperativeHandle( positioningRef, @@ -115,12 +166,17 @@ export function usePositioning(options: PositioningProps): PositioningReturn { const containerRef: React.RefCallback = React.useCallback( node => { + if (attachedContainerRef.current !== node) { + clearContainerAttributes(attachedContainerRef.current); + } + 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 +237,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..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 @@ -1,3 +1,41 @@ # 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. + +## 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, 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. + +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. + +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. 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.