Skip to content

Commit

Permalink
feat(react-positioning): simplify maxSize options (#28649)
Browse files Browse the repository at this point in the history
* wip

* comments

* try to make it better

* chg

* use middleware

* update flag

* test inline overflow

* comment

* use clip to test overflow

* normalize autosize once

* nit fix in test

* rename autoSize to rawAutoSize, and normalizeAutoSize to autoSize

* apply suggestions

* fix test
  • Loading branch information
YuanboXue-Amber authored Jul 27, 2023
1 parent 118a4e2 commit d2d8068
Show file tree
Hide file tree
Showing 8 changed files with 275 additions and 33 deletions.
116 changes: 116 additions & 0 deletions apps/vr-tests-react-components/src/stories/Positioning.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,99 @@ const AutoSize = () => {
);
};

const AutoSizeAsyncContent = () => {
const styles = useStyles();
const [overflowBoundary, setOverflowBoundary] = React.useState<HTMLDivElement | null>(null);
const { containerRef, targetRef } = usePositioning({
position: 'below',
autoSize: true,
overflowBoundary,
});

return (
<div
ref={setOverflowBoundary}
className={styles.boundary}
style={{
display: 'flex',
flexDirection: 'column',
height: 200,
padding: '10px 50px',
position: 'relative',
}}
>
<button ref={targetRef}>Target</button>
<Box ref={containerRef} style={{ overflow: 'auto', border: '3px solid green' }}>
<AsyncFloatingContent />
</Box>
</div>
);
};
const AsyncFloatingContent = () => {
const [isLoaded, setLoaded] = React.useState(false);
const onLoaded = () => setLoaded(true);
return isLoaded ? (
<span id="full-content">
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.
</span>
) : (
<button id="load-content" onClick={onLoaded}>
load
</button>
);
};

const AutoSizeUpdatePosition = () => {
const styles = useStyles();
const [overflowBoundary, setOverflowBoundary] = React.useState<HTMLDivElement | null>(null);
const positioningRef = React.useRef<PositioningImperativeRef>(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 (
<div
ref={setOverflowBoundary}
className={styles.boundary}
style={{
display: 'flex',
flexDirection: 'column',
height: 200,
width: 250,
position: 'relative',
}}
>
<button ref={targetRef} style={{ width: 'fit-content', marginLeft: 100, marginTop: 10 }}>
Target
</button>
<Box ref={containerRef} style={{ overflow: 'clip', overflowClipMargin: 10, border: '3px solid green' }}>
{isLoaded ? (
<div id="full-content" style={{ backgroundColor: 'cornflowerblue', width: 300, height: 100 }} />
) : (
<button id="load-content" onClick={onLoaded}>
load + update position
</button>
)}
</Box>
</div>
);
};

const DisableTether = () => {
const styles = useStyles();
const { containerRef, targetRef } = usePositioning({
Expand Down Expand Up @@ -1019,6 +1112,29 @@ storiesOf('Positioning', module)
.addStory('horizontal overflow', () => <HorizontalOverflow />, { includeRtl: true })
.addStory('pinned', () => <Pinned />)
.addStory('auto size', () => <AutoSize />)
.addStory('auto size with async content', () => (
<StoryWright
steps={new Steps()
.click('#load-content')
.wait('#full-content')
.snapshot('floating element is within the boundary')
.end()}
>
<AutoSizeAsyncContent />
</StoryWright>
))
.addStory('auto size with async content reset styles on updatePosition', () => (
<StoryWright
steps={new Steps()
.click('#load-content')
.wait('#full-content')
.wait(250) // let updatePosition finish
.snapshot('floating element width fills boundary and overflows 10px because of overflow:clip')
.end()}
>
<AutoSizeUpdatePosition />
</StoryWright>
))
.addStory('disable tether', () => <DisableTether />)
.addStory('position fixed', () => <PositionAndAlignProps positionFixed />, { includeRtl: true })
.addStory('virtual element', () => <VirtualElement />)
Expand Down
Original file line number Diff line number Diff line change
@@ -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": "[email protected]",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -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<PositioningOptions, 'overflowBoundary'> {
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);
},
});
}
11 changes: 6 additions & 5 deletions packages/react-components/react-positioning/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement> | 'clippingParents' | 'scrollParent' | 'window';

Expand Down Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -154,7 +155,7 @@ function usePositioningOptions(options: PositioningOptions) {
const {
align,
arrowPadding,
autoSize,
autoSize: rawAutoSize,
coverTarget,
flipBoundary,
offset,
Expand All @@ -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 }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ export * from './toggleScrollListener';
export * from './hasAutoFocusFilter';
export * from './writeArrowUpdates';
export * from './writeContainerupdates';
export * from './normalizeAutoSize';
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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;
}
};

0 comments on commit d2d8068

Please sign in to comment.