Skip to content

Commit

Permalink
Callout positioning: update useMaxHeight hook for top edge alignment;…
Browse files Browse the repository at this point in the history
… account for scroll resizing when choosing callout position. (#29766)

* Don't flip if callout is scrollable

* Fix callout sizing and scroll adjustment

* changefile and add comment

* Remove unnecessary check

* Remove bad import

* Add comment and get rid of helper function

* Undo changes to adjustFitWithinBounds

* Remove newline

* Fix build errors

* Fix visual regressions / runtime error

* Fix build errors and add min scroll height param

* Fix up PR

* Fix package build

* Add opt-in param to scroll resizing behavior

* Fix lint errors

* Fix deps

* Responding to comments

* Stabilize refs

* Responding to PR comments
  • Loading branch information
nipope authored Dec 11, 2023
1 parent 2154896 commit 56a76c9
Show file tree
Hide file tree
Showing 6 changed files with 235 additions and 39 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "fix(Callout): Update useMaxHeight hook for callout to use target top as bottom bound when aligned to top edge; account for scroll resizing when picking the best edge for positioning alignment",
"packageName": "@fluentui/react",
"email": "[email protected]",
"dependentChangeType": "patch"
}
4 changes: 3 additions & 1 deletion packages/react/etc/react.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -3201,13 +3201,15 @@ export interface ICalloutProps extends React_2.HTMLAttributes<HTMLDivElement>, R
hideOverflow?: boolean;
isBeakVisible?: boolean;
layerProps?: ILayerProps;
minimumScrollResizeHeight?: number;
minPagePadding?: number;
onDismiss?: (ev?: Event | React_2.MouseEvent<HTMLElement> | React_2.KeyboardEvent<HTMLElement>) => void;
onLayerMounted?: () => void;
onPositioned?: (positions?: ICalloutPositionedInfo) => void;
onRestoreFocus?: (params: IPopupRestoreFocusParams) => void;
onScroll?: () => void;
popupProps?: IPopupProps;
preferScrollResizePositioning?: boolean;
preventDismissOnEvent?: (ev: Event | React_2.FocusEvent | React_2.KeyboardEvent | React_2.MouseEvent) => boolean;
// @deprecated
preventDismissOnLostFocus?: boolean;
Expand Down Expand Up @@ -10395,7 +10397,7 @@ export enum Position {
}

// @public (undocumented)
export function positionCallout(props: IPositionProps, hostElement: HTMLElement, elementToPosition: HTMLElement, previousPositions?: ICalloutPositionedInfo): ICalloutPositionedInfo;
export function positionCallout(props: IPositionProps, hostElement: HTMLElement, elementToPosition: HTMLElement, previousPositions?: ICalloutPositionedInfo, shouldScroll?: boolean, minimumScrollResizeHeight?: number): ICalloutPositionedInfo;

// @public (undocumented)
export function positionCard(props: IPositionProps, hostElement: HTMLElement, elementToPosition: HTMLElement, previousPositions?: ICalloutPositionedInfo): ICalloutPositionedInfo;
Expand Down
35 changes: 35 additions & 0 deletions packages/react/src/components/Callout/Callout.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,41 @@ export interface ICalloutProps extends React.HTMLAttributes<HTMLDivElement>, Rea
* focus will not be restored automatically, and you'll need to call `params.originalElement.focus()`.
*/
onRestoreFocus?: (params: IPopupRestoreFocusParams) => void;

/**
* The minimum height, in pixels, that the callout will scroll-resize down to on top/bottom edges
* before repositioning to a new edge.
*
* Note: this prop has no effect if `directionalHintFixed=true`.
*
* Note: if `preferScrollResizing=false`, this prop will have no effect because the callout will not scroll-resize.
*
* Note: if `hideOverflow = true`, or if the computed callout style `overflowY` is `hidden` or `clip`,
* the callout will not scroll-resize.
*
* @defaultvalue 200
*/
minimumScrollResizeHeight?: number;

/**
* If true, the callout will scroll-resize when positioning on top / bottom edges,
* rather than repositioning to a new edge.
*
* Example: if `directionalHint=DirectionalHint.bottomAutoEdge`, and the callout content height exceeds
* the vertical space below the callout, the callout will position itself on the bottom edge of the target
* (rather than repositioning to a new edge with more available vertical space),
* and the callout will scroll-resize down to the available space.
*
* Use `minimumScrollResizeHeight` to change the minimum height the callout will resize down to
* before repositioning to another edge (default 200px).
*
* Note: this prop has no effect if `directionalHintFixed=true`.
*
* Note: if `hideOverflow = true`, or if the computed callout style `overflowY` is `hidden` or `clip`,
* the callout will not prefer scroll-resized positions (i.e. this prop will be ignored)
* @defaultvalue false
*/
preferScrollResizePositioning?: boolean;
}

/**
Expand Down
79 changes: 72 additions & 7 deletions packages/react/src/components/Callout/CalloutContent.base.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
getPropsWithDefaults,
Async,
} from '../../Utilities';
import { calculateGapSpace, getRectangleFromTarget } from '../../utilities/positioning/positioning';
import { positionCallout, RectangleEdge, positionCard, getBoundsFromTargetWindow } from '../../Positioning';
import { Popup } from '../../Popup';
import { classNamesFunction } from '../../Utilities';
Expand All @@ -20,6 +21,7 @@ import type { ICalloutProps, ICalloutContentStyleProps, ICalloutContentStyles }
import type { Point, IRectangle } from '../../Utilities';
import type { ICalloutPositionedInfo, IPositionProps, IPosition } from '../../Positioning';
import type { Target } from '@fluentui/react-hooks';
import { useWindow } from '@fluentui/react-window-provider';

const COMPONENT_NAME = 'CalloutContentBase';

Expand Down Expand Up @@ -115,17 +117,36 @@ function useBounds(
* (Hook) to return the maximum available height for the Callout to render into.
*/
function useMaxHeight(
{ calloutMaxHeight, finalHeight, directionalHint, directionalHintFixed, hidden }: ICalloutProps,
{
calloutMaxHeight,
finalHeight,
directionalHint,
directionalHintFixed,
hidden,
gapSpace,
beakWidth,
isBeakVisible,
}: ICalloutProps,
getBounds: () => IRectangle | undefined,
targetRef: React.RefObject<Element | MouseEvent | Point | null>,
positions?: ICalloutPositionedInfo,
) {
const [maxHeight, setMaxHeight] = React.useState<number | undefined>();
const { top, bottom } = positions?.elementPosition ?? {};
const targetRect = targetRef?.current ? getRectangleFromTarget(targetRef.current) : undefined;

React.useEffect(() => {
const { top: topBounds, bottom: bottomBounds } = getBounds() ?? {};
const bounds = getBounds() ?? ({} as IRectangle);
const { top: topBounds } = bounds;
let { bottom: bottomBounds } = bounds;
let calculatedHeight: number | undefined;

// If aligned to top edge of target, update bottom bounds to the top of the target
// (accounting for gap space and beak)
if (positions?.targetEdge === RectangleEdge.top && targetRect?.top) {
bottomBounds = targetRect.top - calculateGapSpace(isBeakVisible, beakWidth, gapSpace);
}

if (typeof top === 'number' && bottomBounds) {
calculatedHeight = bottomBounds - top;
} else if (typeof bottom === 'number' && typeof topBounds === 'number' && bottomBounds) {
Expand All @@ -142,7 +163,21 @@ function useMaxHeight(
} else {
setMaxHeight(undefined);
}
}, [bottom, calloutMaxHeight, finalHeight, directionalHint, directionalHintFixed, getBounds, hidden, positions, top]);
}, [
bottom,
calloutMaxHeight,
finalHeight,
directionalHint,
directionalHintFixed,
getBounds,
hidden,
positions,
top,
gapSpace,
beakWidth,
isBeakVisible,
targetRect,
]);

return maxHeight;
}
Expand All @@ -156,12 +191,31 @@ function usePositions(
calloutElement: HTMLDivElement | null,
targetRef: React.RefObject<Element | MouseEvent | Point | null>,
getBounds: () => IRectangle | undefined,
popupRef: React.RefObject<HTMLDivElement>,
) {
const [positions, setPositions] = React.useState<ICalloutPositionedInfo>();
const positionAttempts = React.useRef(0);
const previousTarget = React.useRef<Target>();
const async = useAsync();
const { hidden, target, finalHeight, calloutMaxHeight, onPositioned, directionalHint } = props;
const {
hidden,
target,
finalHeight,
calloutMaxHeight,
onPositioned,
directionalHint,
hideOverflow,
preferScrollResizePositioning,
} = props;

const win = useWindow();
const localRef = React.useRef<HTMLDivElement | null>();
let popupStyles: CSSStyleDeclaration | undefined;
if (localRef.current !== popupRef.current) {
localRef.current = popupRef.current;
popupStyles = popupRef.current ? win?.getComputedStyle(popupRef.current) : undefined;
}
const popupOverflowY = popupStyles?.overflowY;

React.useEffect(() => {
if (!hidden) {
Expand All @@ -181,11 +235,16 @@ function usePositions(

const previousPositions = previousTarget.current === target ? positions : undefined;

// only account for scroll resizing if styles allow callout to scroll
// (popup styles determine if callout will scroll)
const isOverflowYHidden = hideOverflow || popupOverflowY === 'clip' || popupOverflowY === 'hidden';
const shouldScroll = preferScrollResizePositioning && !isOverflowYHidden;

// If there is a finalHeight given then we assume that the user knows and will handle
// additional positioning adjustments so we should call positionCard
const newPositions: ICalloutPositionedInfo = finalHeight
? positionCard(currentProps, hostElement.current, dupeCalloutElement, previousPositions)
: positionCallout(currentProps, hostElement.current, dupeCalloutElement, previousPositions);
: positionCallout(currentProps, hostElement.current, dupeCalloutElement, previousPositions, shouldScroll);

// clean up duplicate calloutElement
calloutElement.parentElement?.removeChild(dupeCalloutElement);
Expand Down Expand Up @@ -233,6 +292,9 @@ function usePositions(
positions,
props,
target,
hideOverflow,
preferScrollResizePositioning,
popupOverflowY,
]);

return positions;
Expand Down Expand Up @@ -431,6 +493,8 @@ export const CalloutContentBase: React.FunctionComponent<ICalloutProps> = React.
} = props;

const hostElement = React.useRef<HTMLDivElement>(null);
const popupRef = React.useRef<HTMLDivElement>(null);
const mergedPopupRefs = useMergedRefs(popupRef, popupProps?.ref);
const [calloutElement, setCalloutElement] = React.useState<HTMLDivElement | null>(null);
const calloutCallback = React.useCallback((calloutEl: any) => {
setCalloutElement(calloutEl);
Expand All @@ -441,8 +505,8 @@ export const CalloutContentBase: React.FunctionComponent<ICalloutProps> = React.
current: calloutElement,
});
const getBounds = useBounds(props, targetRef, targetWindow);
const positions = usePositions(props, hostElement, calloutElement, targetRef, getBounds);
const maxHeight = useMaxHeight(props, getBounds, positions);
const positions = usePositions(props, hostElement, calloutElement, targetRef, getBounds, mergedPopupRefs);
const maxHeight = useMaxHeight(props, getBounds, targetRef, positions);
const [mouseDownOnPopup, mouseUpOnPopup] = useDismissHandlers(
props,
positions,
Expand Down Expand Up @@ -530,6 +594,7 @@ export const CalloutContentBase: React.FunctionComponent<ICalloutProps> = React.
shouldRestoreFocus={shouldRestoreFocus}
style={overflowStyle}
{...popupProps}
ref={mergedPopupRefs}
>
{children}
</Popup>
Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/utilities/positioning/positioning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,8 @@ describe('Callout Positioning', () => {
basicTestCase.bounds,
__positioningTestPackage._getPositionData(DirectionalHint.bottomLeftEdge),
basicTestCase.beakWidth,
false, // shouldScroll value
undefined, // minimumScrollResizeHeight value
true, // directionalHintFixed value
);

Expand Down
Loading

0 comments on commit 56a76c9

Please sign in to comment.