Skip to content

Commit

Permalink
feat(react-positioning): add option shiftToCoverTarget (#33468)
Browse files Browse the repository at this point in the history
  • Loading branch information
YuanboXue-Amber authored Dec 16, 2024
1 parent a2c5f8d commit 27a945e
Show file tree
Hide file tree
Showing 8 changed files with 289 additions and 3 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import * as React from 'react';
import {
Button,
makeStyles,
SpinButton,
Menu,
MenuTrigger,
MenuPopover,
MenuList,
MenuItem,
PositioningImperativeRef,
useMergedRefs,
Checkbox,
RadioGroup,
Field,
Radio,
PositioningProps,
} from '@fluentui/react-components';

const useStyles = makeStyles({
boundary: {
border: '2px dashed red',
width: '300px',
height: '300px',
overflow: 'auto',
resize: 'both',
},
trigger: {
display: 'block',
width: '150px',
margin: '200px auto',
},
});

const ResizableBoundary = React.forwardRef<
HTMLDivElement,
{
onResize: ResizeObserverCallback;
children: React.ReactNode;
}
>(({ onResize, children }, ref) => {
const containerRef = React.useRef<HTMLDivElement | null>(null);

React.useEffect(() => {
if (containerRef.current) {
const resizeObserver = new ResizeObserver(onResize);
resizeObserver.observe(containerRef.current);

return () => {
resizeObserver.disconnect();
};
}
}, [onResize]);

const styles = useStyles();

return (
<div ref={useMergedRefs(ref, containerRef)} className={styles.boundary}>
{children}
</div>
);
});

export const CoverTargetForSmallViewport = () => {
const styles = useStyles();
const [boundaryRef, setBoundaryRef] = React.useState<HTMLDivElement | null>(null);

const [menuItemCount, setMenuItemCount] = React.useState(6);

const positioningRef = React.useRef<PositioningImperativeRef>(null);

const [open, setOpen] = React.useState(false);
const [menuPosition, setMenuPosition] = React.useState<PositioningProps['position']>('above');

return (
<>
<div>
<Checkbox label="Open" checked={open} onChange={(e, data) => setOpen(data.checked as boolean)} />{' '}
<Field label="Menu position">
<RadioGroup
value={menuPosition}
onChange={(_, data) => setMenuPosition(data.value as PositioningProps['position'])}
>
<Radio value="above" label="above" />
<Radio value="after" label="after" />
</RadioGroup>
</Field>
<Field label="Menu Item Count">
<SpinButton value={menuItemCount} onChange={(_e, { value }) => value && setMenuItemCount(value)} />
</Field>
</div>
<ResizableBoundary
ref={setBoundaryRef}
onResize={() => {
positioningRef.current?.updatePosition();
}}
>
<Menu
open={open}
positioning={{
positioningRef,
overflowBoundary: boundaryRef,
flipBoundary: boundaryRef,
autoSize: true,
shiftToCoverTarget: true,
position: menuPosition,
}}
>
<MenuTrigger disableButtonEnhancement>
<Button className={styles.trigger}>Open Menu</Button>
</MenuTrigger>
<MenuPopover>
<MenuList>
{Array.from({ length: menuItemCount }, (_, i) => (
<MenuItem>Item {i}</MenuItem>
))}
</MenuList>
</MenuPopover>
</Menu>
</ResizableBoundary>
</>
);
};

CoverTargetForSmallViewport.parameters = {
docs: {
description: {
story:
"`shiftToCoverTarget` is a positioning option that allows the positioned element to shift and cover the target element when there isn't enough space available to fit it.",
},
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export { MatchTargetSize } from './MatchTargetSize.stories';
export { DisableTransform } from './PositioningDisableTransform.stories';
export { ListenToUpdates } from './PositioningListenToUpdates.stories';
export { AutoSizeForSmallViewport } from './PositioningAutoSize.stories';
export { CoverTargetForSmallViewport } from './PositioningShiftToCoverTarget.stories';
export { FallbackPositions } from './PositioningFallbackPositions.stories';

export default {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -843,6 +843,102 @@ const TargetDisplayNone = () => {
);
};

const ShiftToCoverTargetWithAutoSize = () => {
const styles = useStyles();
const [overflowBoundary, setOverflowBoundary] = React.useState<HTMLDivElement | null>(null);
const { containerRef, targetRef } = usePositioning({
position: 'below',
overflowBoundary,
shiftToCoverTarget: true,
autoSize: 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' }}>
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. Praesent semper feugiat nibh sed pulvinar proin gravida hendrerit lectus. Porta
nibh venenatis cras sed felis eget. Enim sed faucibus turpis in. Non blandit massa enim nec dui nunc mattis. Ut
eu sem integer vitae justo.
</Box>
</div>
);
};

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

return (
<div
ref={setOverflowBoundary}
className={styles.boundary}
style={{
height: 200,
width: 300,
padding: '50px 50px',
boxSizing: 'border-box',
position: 'relative',
}}
>
<button ref={targetRef}>Target</button>
<Box ref={containerRef} style={{ overflow: 'auto', border: '3px solid green', padding: 0 }}>
<Box style={{ maxWidth: 180 }}>
<AsyncFloatingContent />
</Box>
</Box>
</div>
);
};

const ShiftToCoverTargetAsyncContent = () => {
const styles = useStyles();
const [overflowBoundary, setOverflowBoundary] = React.useState<HTMLDivElement | null>(null);
const { containerRef, targetRef } = usePositioning({
position: 'below',
overflowBoundary,
shiftToCoverTarget: true,
autoSize: 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' }}>
<AsyncFloatingContent />
</Box>
</div>
);
};

export default {
title: 'Positioning',

Expand Down Expand Up @@ -1033,3 +1129,32 @@ export const _TargetDisplayNone = () => (
</StoryWright>
);
_TargetDisplayNone.storyName = 'Target display none';

export const _ShiftToCoverTargetWithAutoSize = () => <ShiftToCoverTargetWithAutoSize />;
_ShiftToCoverTargetWithAutoSize.storyName = 'shiftToCoverTarget with autoSize';

export const _ShiftToCoverTargetAsyncContent = () => (
<StoryWright
steps={new Steps()
.click('#load-content')
.wait('#full-content')
.snapshot('floating element is within the boundary')
.end()}
>
<ShiftToCoverTargetAsyncContent />
</StoryWright>
);
_ShiftToCoverTargetAsyncContent.storyName = 'shiftToCoverTarget with autoSize and async content';

export const _ShiftToCoverTargetHorizontal = () => (
<StoryWright
steps={new Steps()
.click('#load-content')
.wait('#full-content')
.snapshot('floating element is within the boundary')
.end()}
>
<ShiftToCoverTargetAsyncContentHorizontal />
</StoryWright>
);
_ShiftToCoverTargetHorizontal.storyName = 'shiftToCoverTarget with autoSize and async content - horizontal';
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "Add positioning option `shiftToCoverTarget` that allows the positioned element to shift to cover the target when there is not enough available space",
"packageName": "@fluentui/react-positioning",
"email": "[email protected]",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export type PositioningImperativeRef = {
};

// @public
export interface PositioningProps extends Pick<PositioningOptions, 'align' | 'arrowPadding' | 'autoSize' | 'coverTarget' | 'fallbackPositions' | 'flipBoundary' | 'offset' | 'overflowBoundary' | 'overflowBoundaryPadding' | 'pinned' | 'position' | 'strategy' | 'useTransform' | 'matchTargetSize' | 'onPositioningEnd' | 'disableUpdateOnResize'> {
export interface PositioningProps extends Pick<PositioningOptions, 'align' | 'arrowPadding' | 'autoSize' | 'coverTarget' | 'fallbackPositions' | 'flipBoundary' | 'offset' | 'overflowBoundary' | 'overflowBoundaryPadding' | 'pinned' | 'position' | 'strategy' | 'useTransform' | 'matchTargetSize' | 'onPositioningEnd' | 'disableUpdateOnResize' | 'shiftToCoverTarget'> {
positioningRef?: React_2.Ref<PositioningImperativeRef>;
target?: TargetElement | null;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { PositioningOptions } from '../types';
import { getBoundary, toFloatingUIPadding } from '../utils/index';

export interface ShiftMiddlewareOptions
extends Pick<PositioningOptions, 'overflowBoundary' | 'overflowBoundaryPadding'> {
extends Pick<PositioningOptions, 'overflowBoundary' | 'overflowBoundaryPadding' | 'shiftToCoverTarget'> {
hasScrollableElement?: boolean;
disableTether?: PositioningOptions['unstable_disableTether'];
container: HTMLElement | null;
Expand All @@ -14,10 +14,22 @@ export interface ShiftMiddlewareOptions
* Wraps the floating UI shift middleware for easier usage of our options
*/
export function shift(options: ShiftMiddlewareOptions) {
const { hasScrollableElement, disableTether, overflowBoundary, container, overflowBoundaryPadding, isRtl } = options;
const {
hasScrollableElement,
shiftToCoverTarget,
disableTether,
overflowBoundary,
container,
overflowBoundaryPadding,
isRtl,
} = options;

return baseShift({
...(hasScrollableElement && { boundary: 'clippingAncestors' }),
...(shiftToCoverTarget && {
crossAxis: true,
limiter: limitShift({ crossAxis: true, mainAxis: false }),
}),
...(disableTether && {
crossAxis: disableTether === 'all',
limiter: limitShift({ crossAxis: disableTether !== 'all', mainAxis: false }),
Expand Down
7 changes: 7 additions & 0 deletions packages/react-components/react-positioning/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,12 @@ export interface PositioningOptions {
* Disables the resize observer that updates position on target or dimension change
*/
disableUpdateOnResize?: boolean;

/**
* When true, the positioned element will shift to cover the target element when there's not enough space.
* @default false
*/
shiftToCoverTarget?: boolean;
}

/**
Expand All @@ -221,6 +227,7 @@ export interface PositioningProps
| 'matchTargetSize'
| 'onPositioningEnd'
| 'disableUpdateOnResize'
| 'shiftToCoverTarget'
> {
/** An imperative handle to Popper methods. */
positioningRef?: React.Ref<PositioningImperativeRef>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ function usePositioningOptions(options: PositioningOptions) {
useTransform,
matchTargetSize,
disableUpdateOnResize = false,
shiftToCoverTarget,
} = options;

const { dir, targetDocument } = useFluent();
Expand All @@ -205,6 +206,7 @@ function usePositioningOptions(options: PositioningOptions) {
disableTether,
overflowBoundaryPadding,
isRtl,
shiftToCoverTarget,
}),
autoSize && maxSizeMiddleware(autoSize, { container, overflowBoundary, overflowBoundaryPadding, isRtl }),
intersectingMiddleware(),
Expand Down

0 comments on commit 27a945e

Please sign in to comment.