diff --git a/apps/vr-tests-react-components/src/stories/Positioning.stories.tsx b/apps/vr-tests-react-components/src/stories/Positioning.stories.tsx index 46fb0e07605fd..37034935a7d59 100644 --- a/apps/vr-tests-react-components/src/stories/Positioning.stories.tsx +++ b/apps/vr-tests-react-components/src/stories/Positioning.stories.tsx @@ -477,6 +477,99 @@ const AutoSize = () => { ); }; +const AutoSizeAsyncContent = () => { + const styles = useStyles(); + const [overflowBoundary, setOverflowBoundary] = React.useState(null); + const { containerRef, targetRef } = usePositioning({ + position: 'below', + autoSize: true, + overflowBoundary, + }); + + return ( +
+ + + + +
+ ); +}; +const AsyncFloatingContent = () => { + const [isLoaded, setLoaded] = React.useState(false); + const onLoaded = () => setLoaded(true); + return isLoaded ? ( + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua. In fermentum et sollicitudin ac orci phasellus egestas. Facilisi cras fermentum odio eu feugiat + pretium nibh ipsum consequat. + + ) : ( + + ); +}; + +const AutoSizeUpdatePosition = () => { + const styles = useStyles(); + const [overflowBoundary, setOverflowBoundary] = React.useState(null); + const positioningRef = React.useRef(null); + const { containerRef, targetRef } = usePositioning({ + position: 'below', + align: 'start', + autoSize: true, + overflowBoundary, + positioningRef, + }); + + const [isLoaded, setLoaded] = React.useState(false); + const onLoaded = () => setLoaded(true); + + React.useEffect(() => { + if (isLoaded) { + positioningRef.current?.updatePosition(); + } + }, [isLoaded]); + + return ( +
+ + + {isLoaded ? ( +
+ ) : ( + + )} + +
+ ); +}; + const DisableTether = () => { const styles = useStyles(); const { containerRef, targetRef } = usePositioning({ @@ -1019,6 +1112,29 @@ storiesOf('Positioning', module) .addStory('horizontal overflow', () => , { includeRtl: true }) .addStory('pinned', () => ) .addStory('auto size', () => ) + .addStory('auto size with async content', () => ( + + + + )) + .addStory('auto size with async content reset styles on updatePosition', () => ( + + + + )) .addStory('disable tether', () => ) .addStory('position fixed', () => , { includeRtl: true }) .addStory('virtual element', () => ) diff --git a/change/@fluentui-react-positioning-bc570315-c0a5-4a0f-a762-5fb18e58b4e8.json b/change/@fluentui-react-positioning-bc570315-c0a5-4a0f-a762-5fb18e58b4e8.json new file mode 100644 index 0000000000000..d3bfd9c2f1b81 --- /dev/null +++ b/change/@fluentui-react-positioning-bc570315-c0a5-4a0f-a762-5fb18e58b4e8.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: simplify autoSize options to make 'always'/'height-always'/'width-always' equivalent to true/'height'/'width'.", + "packageName": "@fluentui/react-positioning", + "email": "yuanboxue@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-positioning/src/middleware/maxSize.ts b/packages/react-components/react-positioning/src/middleware/maxSize.ts index 3c3d4ed20143d..760668c41700f 100644 --- a/packages/react-components/react-positioning/src/middleware/maxSize.ts +++ b/packages/react-components/react-positioning/src/middleware/maxSize.ts @@ -1,41 +1,68 @@ import { size } from '@floating-ui/dom'; import type { Middleware } from '@floating-ui/dom'; -import type { PositioningOptions } from '../types'; +import type { NormalizedAutoSize, PositioningOptions } from '../types'; import { getBoundary } from '../utils/getBoundary'; export interface MaxSizeMiddlewareOptions extends Pick { container: HTMLElement | null; } -export function maxSize(autoSize: PositioningOptions['autoSize'], options: MaxSizeMiddlewareOptions): Middleware { +/** + * floating-ui `size` middleware uses floating element's height/width to calculate available height/width. + * This middleware only runs once per lifecycle, resetting styles applied by maxSize from previous lifecycle. + * Then floating element's original size is restored and `size` middleware can calculate available height/width correctly. + */ +export const resetMaxSize = (autoSize: NormalizedAutoSize): Middleware => ({ + name: 'resetMaxSize', + fn({ middlewareData: { maxSizeAlreadyReset }, elements }) { + if (maxSizeAlreadyReset) { + return {}; + } + + const { applyMaxWidth, applyMaxHeight } = autoSize; + if (applyMaxWidth) { + elements.floating.style.removeProperty('box-sizing'); + elements.floating.style.removeProperty('max-width'); + elements.floating.style.removeProperty('width'); + } + if (applyMaxHeight) { + elements.floating.style.removeProperty('box-sizing'); + elements.floating.style.removeProperty('max-height'); + elements.floating.style.removeProperty('height'); + } + + return { + data: { maxSizeAlreadyReset: true }, + reset: { rects: true }, + }; + }, +}); + +export function maxSize(autoSize: NormalizedAutoSize, options: MaxSizeMiddlewareOptions): Middleware { const { container, overflowBoundary } = options; return size({ ...(overflowBoundary && { altBoundary: true, boundary: getBoundary(container, overflowBoundary) }), apply({ availableHeight, availableWidth, elements, rects }) { - if (autoSize) { + const applyMaxSizeStyles = (apply: boolean, dimension: 'width' | 'height', availableSize: number) => { + if (!apply) { + return; + } + elements.floating.style.setProperty('box-sizing', 'border-box'); - } - - const applyMaxWidth = autoSize === 'always' || autoSize === 'width-always'; - const widthOverflow = rects.floating.width > availableWidth && (autoSize === true || autoSize === 'width'); - - const applyMaxHeight = autoSize === 'always' || autoSize === 'height-always'; - const heightOverflow = rects.floating.height > availableHeight && (autoSize === true || autoSize === 'height'); - - if (applyMaxHeight || heightOverflow) { - elements.floating.style.setProperty('max-height', `${availableHeight}px`); - } - if (heightOverflow) { - elements.floating.style.setProperty('height', `${availableHeight}px`); - elements.floating.style.setProperty('overflow-y', 'auto'); - } - - if (applyMaxWidth || widthOverflow) { - elements.floating.style.setProperty('max-width', `${availableWidth}px`); - } - if (widthOverflow) { - elements.floating.style.setProperty('width', `${availableWidth}px`); - elements.floating.style.setProperty('overflow-x', 'auto'); - } + elements.floating.style.setProperty(`max-${dimension}`, `${availableSize}px`); + + if (rects.floating[dimension] > availableSize) { + elements.floating.style.setProperty(dimension, `${availableSize}px`); + + const axis = dimension === 'width' ? 'x' : 'y'; + if (!elements.floating.style.getPropertyValue(`overflow-${axis}`)) { + elements.floating.style.setProperty(`overflow-${axis}`, 'auto'); + } + } + }; + + const { applyMaxWidth, applyMaxHeight } = autoSize; + applyMaxSizeStyles(applyMaxWidth, 'width', availableWidth); + applyMaxSizeStyles(applyMaxHeight, 'height', availableHeight); }, }); } diff --git a/packages/react-components/react-positioning/src/types.ts b/packages/react-components/react-positioning/src/types.ts index 11297197926f2..5ec16065604f8 100644 --- a/packages/react-components/react-positioning/src/types.ts +++ b/packages/react-components/react-positioning/src/types.ts @@ -49,6 +49,7 @@ export type Position = 'above' | 'below' | 'before' | 'after'; export type Alignment = 'top' | 'bottom' | 'start' | 'end' | 'center'; export type AutoSize = 'height' | 'height-always' | 'width' | 'width-always' | 'always' | boolean; +export type NormalizedAutoSize = { applyMaxWidth: boolean; applyMaxHeight: boolean }; export type Boundary = HTMLElement | Array | 'clippingParents' | 'scrollParent' | 'window'; @@ -136,11 +137,11 @@ export interface PositioningOptions { arrowPadding?: number; /** - * Applies max-height and max-width on the positioned element to fit it within the available space in viewport. - * true enables this for both width and height when overflow happens. - * 'always' applies `max-height`/`max-width` regardless of overflow. - * 'height' applies `max-height` when overflow happens, and 'width' for `max-width` - * `height-always` applies `max-height` regardless of overflow, and 'width-always' for always applying `max-width` + * Applies styles on the positioned element to fit it within the available space in viewport. + * - true: set styles for max height/width. + * - 'height': set styles for max height. + * - 'width'': set styles for max width. + * Note that options 'always'/'height-always'/'width-always' are now obsolete, and equivalent to true/'height'/'width'. */ autoSize?: AutoSize; diff --git a/packages/react-components/react-positioning/src/usePositioning.ts b/packages/react-components/react-positioning/src/usePositioning.ts index ce5bcbd0a3464..9a678097adaaf 100644 --- a/packages/react-components/react-positioning/src/usePositioning.ts +++ b/packages/react-components/react-positioning/src/usePositioning.ts @@ -10,12 +10,13 @@ import type { TargetElement, UsePositioningReturn, } from './types'; -import { useCallbackRef, toFloatingUIPlacement, hasAutofocusFilter, hasScrollParent } from './utils'; +import { useCallbackRef, toFloatingUIPlacement, hasAutofocusFilter, hasScrollParent, normalizeAutoSize } from './utils'; import { shift as shiftMiddleware, flip as flipMiddleware, coverTarget as coverTargetMiddleware, maxSize as maxSizeMiddleware, + resetMaxSize as resetMaxSizeMiddleware, offset as offsetMiddleware, intersecting as intersectingMiddleware, } from './middleware'; @@ -154,7 +155,7 @@ function usePositioningOptions(options: PositioningOptions) { const { align, arrowPadding, - autoSize, + autoSize: rawAutoSize, coverTarget, flipBoundary, offset, @@ -173,12 +174,14 @@ function usePositioningOptions(options: PositioningOptions) { const { dir } = useFluent(); const isRtl = dir === 'rtl'; const positionStrategy: Strategy = strategy ?? positionFixed ? 'fixed' : 'absolute'; + const autoSize = normalizeAutoSize(rawAutoSize); return React.useCallback( (container: HTMLElement | null, arrow: HTMLElement | null) => { const hasScrollableElement = hasScrollParent(container); const middleware = [ + autoSize && resetMaxSizeMiddleware(autoSize), offset && offsetMiddleware(offset), coverTarget && coverTargetMiddleware(), !pinned && flipMiddleware({ container, flipBoundary, hasScrollableElement, isRtl, fallbackPositions }), diff --git a/packages/react-components/react-positioning/src/utils/index.ts b/packages/react-components/react-positioning/src/utils/index.ts index 188c70969cdab..e538bdfaee66a 100644 --- a/packages/react-components/react-positioning/src/utils/index.ts +++ b/packages/react-components/react-positioning/src/utils/index.ts @@ -13,3 +13,4 @@ export * from './toggleScrollListener'; export * from './hasAutoFocusFilter'; export * from './writeArrowUpdates'; export * from './writeContainerupdates'; +export * from './normalizeAutoSize'; diff --git a/packages/react-components/react-positioning/src/utils/normalizeAutoSize.test.ts b/packages/react-components/react-positioning/src/utils/normalizeAutoSize.test.ts new file mode 100644 index 0000000000000..9a826ad95c909 --- /dev/null +++ b/packages/react-components/react-positioning/src/utils/normalizeAutoSize.test.ts @@ -0,0 +1,53 @@ +import { normalizeAutoSize } from './normalizeAutoSize'; + +describe('normalizeAutoSize', () => { + const cases = [ + [ + 'always', + { + applyMaxWidth: true, + applyMaxHeight: true, + }, + ], + [ + true, + { + applyMaxWidth: true, + applyMaxHeight: true, + }, + ], + [ + 'width-always', + { + applyMaxWidth: true, + applyMaxHeight: false, + }, + ], + [ + 'width', + { + applyMaxWidth: true, + applyMaxHeight: false, + }, + ], + [ + 'height-always', + { + applyMaxWidth: false, + applyMaxHeight: true, + }, + ], + [ + 'height', + { + applyMaxWidth: false, + applyMaxHeight: true, + }, + ], + [false, false], + ] as const; + + it.each(cases)('should normalize autoSize', (rawAutoSize, normalizedAutoSize) => { + expect(normalizeAutoSize(rawAutoSize)).toEqual(normalizedAutoSize); + }); +}); diff --git a/packages/react-components/react-positioning/src/utils/normalizeAutoSize.ts b/packages/react-components/react-positioning/src/utils/normalizeAutoSize.ts new file mode 100644 index 0000000000000..49701999ccd6c --- /dev/null +++ b/packages/react-components/react-positioning/src/utils/normalizeAutoSize.ts @@ -0,0 +1,34 @@ +import type { NormalizedAutoSize, PositioningOptions } from '../types'; + +/** + * AutoSizes contains many options from historic implementation. + * Now options 'always'/'height-always'/'width-always' are obsolete. + * This function maps them to true/'height'/'width' + */ +export const normalizeAutoSize = (autoSize?: PositioningOptions['autoSize']): NormalizedAutoSize | false => { + switch (autoSize) { + case 'always': + case true: + return { + applyMaxWidth: true, + applyMaxHeight: true, + }; + + case 'width-always': + case 'width': + return { + applyMaxWidth: true, + applyMaxHeight: false, + }; + + case 'height-always': + case 'height': + return { + applyMaxWidth: false, + applyMaxHeight: true, + }; + + default: + return false; + } +};