Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(react-positioning): simplify maxSize options #28649

Merged
merged 14 commits into from
Jul 27, 2023
114 changes: 114 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,97 @@ const AutoSize = () => {
);
};

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

const [isLoaded, setLoaded] = React.useState(false);
const onLoaded = () => setLoaded(true);

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' }}>
{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>
)}
</Box>
</div>
);
};

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 +1110,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')
.waitForNotFound('#load-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": "yuanboxue@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,35 +6,105 @@ export interface MaxSizeMiddlewareOptions extends Pick<PositioningOptions, 'over
container: HTMLElement | null;
}

/**
* AutoSizes contains many options from historic implementation.
* Now options 'always'/'height-always'/'width-always' are obsolete.
* This function maps them to true/'height'/'width'
*/
const normalizeAutoSize = (
autoSize?: PositioningOptions['autoSize'],
): { applyMaxWidth: boolean; applyMaxHeight: boolean } => {
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 {
applyMaxWidth: false,
applyMaxHeight: false,
};
}
};

/**
* 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: PositioningOptions['autoSize']): Middleware => ({
name: 'resetMaxSize',
fn({ middlewareData: { maxSizeAlreadyReset }, elements }) {
if (maxSizeAlreadyReset) {
return {};
}

const { applyMaxWidth, applyMaxHeight } = normalizeAutoSize(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: PositioningOptions['autoSize'], options: MaxSizeMiddlewareOptions): Middleware {
const { container, overflowBoundary } = options;
return size({
...(overflowBoundary && { altBoundary: true, boundary: getBoundary(container, overflowBoundary) }),
apply({ availableHeight, availableWidth, elements, rects }) {
if (autoSize) {
elements.floating.style.setProperty('box-sizing', 'border-box');
}
const { applyMaxWidth, applyMaxHeight } = normalizeAutoSize(autoSize);

const applyMaxWidth = autoSize === 'always' || autoSize === 'width-always';
const widthOverflow = rects.floating.width > availableWidth && (autoSize === true || autoSize === 'width');
const widthOverflow = rects.floating.width > availableWidth;
const heightOverflow = rects.floating.height > availableHeight;

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) {
if (applyMaxWidth) {
elements.floating.style.setProperty('box-sizing', 'border-box');
elements.floating.style.setProperty('max-width', `${availableWidth}px`);
if (widthOverflow) {
elements.floating.style.setProperty('width', `${availableWidth}px`);
if (!elements.floating.style.overflowX) {
elements.floating.style.setProperty('overflow-x', 'auto');
}
}
}
if (widthOverflow) {
elements.floating.style.setProperty('width', `${availableWidth}px`);
elements.floating.style.setProperty('overflow-x', 'auto');

if (applyMaxHeight) {
elements.floating.style.setProperty('box-sizing', 'border-box');
elements.floating.style.setProperty('max-height', `${availableHeight}px`);
if (heightOverflow) {
elements.floating.style.setProperty('height', `${availableHeight}px`);
if (!elements.floating.style.overflowY) {
elements.floating.style.setProperty('overflow-y', 'auto');
}
}
}
},
});
Expand Down
10 changes: 5 additions & 5 deletions packages/react-components/react-positioning/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,11 +136,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 @@ -16,6 +16,7 @@ import {
flip as flipMiddleware,
coverTarget as coverTargetMiddleware,
maxSize as maxSizeMiddleware,
resetMaxSize as resetMaxSizeMiddleware,
offset as offsetMiddleware,
intersecting as intersectingMiddleware,
} from './middleware';
Expand Down Expand Up @@ -179,6 +180,7 @@ function usePositioningOptions(options: PositioningOptions) {
const hasScrollableElement = hasScrollParent(container);

const middleware = [
autoSize && resetMaxSizeMiddleware(autoSize),
offset && offsetMiddleware(offset),
coverTarget && coverTargetMiddleware(),
!pinned && flipMiddleware({ container, flipBoundary, hasScrollableElement, isRtl, fallbackPositions }),
Expand Down