diff --git a/.changeset/kind-socks-report.md b/.changeset/kind-socks-report.md new file mode 100644 index 000000000..8202c9cbf --- /dev/null +++ b/.changeset/kind-socks-report.md @@ -0,0 +1,8 @@ +--- +"@khanacademy/wonder-blocks-tooltip": minor +--- + +Update tooltip implementation of Popper to: + +- Ensure that the popper doesn't disappear when the referenced element is not in view in very small screen sizes. This ensures customers can interact with the popper in extra small screen sizes or +400% zoom without the popper randomly disappearing. +- Addition of an optional property to set what the root boundary is for the popper behavior. This is set to "viewport" by default, causing the popper to be positioned based on the user's viewport. If set to "document", it will position itself based on where there is available room within the document body \ No newline at end of file diff --git a/__docs__/wonder-blocks-popover/popover.argtypes.tsx b/__docs__/wonder-blocks-popover/popover.argtypes.tsx index 5444bb824..e82f81e96 100644 --- a/__docs__/wonder-blocks-popover/popover.argtypes.tsx +++ b/__docs__/wonder-blocks-popover/popover.argtypes.tsx @@ -122,6 +122,17 @@ export default { type: "boolean", }, }, + rootBoundary: { + description: + `Optional property to set what the root boundary is for the popper behavior. ` + + `This is set to "viewport" by default, causing the popper to be positioned based ` + + `on the user's viewport. If set to "document", it will position itself based ` + + `on where there is available room within the document body.`, + control: { + type: "select", + options: ["viewport", "document"], + }, + }, portal: { description: `Optional property to enable the portal functionality of popover.` + diff --git a/__docs__/wonder-blocks-popover/popover.stories.tsx b/__docs__/wonder-blocks-popover/popover.stories.tsx index 05ebdc7e9..6c613dcad 100644 --- a/__docs__/wonder-blocks-popover/popover.stories.tsx +++ b/__docs__/wonder-blocks-popover/popover.stories.tsx @@ -735,6 +735,42 @@ export const PopoverAlignment: StoryComponentType = { ), }; +export const WithDocumentRootBoundary: StoryComponentType = () => { + return ( + + ( + + + + } + /> + )} + placement="top" + > + + + + ); +}; + +WithDocumentRootBoundary.parameters = { + docs: { + description: { + story: `Sometimes you need to change the underlining behavior to position the + Popover by the whole webpage (document) instead of by the viewport. This is a + useful tool for popovers with large content that might not fit in small screen + sizes or at 400% zoom. For this reason, you can make use of the + \`rootBoundary\` prop:`, + }, + }, +}; + /** * With custom aria-label - overrides the default aria-labelledby */ diff --git a/packages/wonder-blocks-popover/src/components/popover.tsx b/packages/wonder-blocks-popover/src/components/popover.tsx index 077c32e32..ef8370b28 100644 --- a/packages/wonder-blocks-popover/src/components/popover.tsx +++ b/packages/wonder-blocks-popover/src/components/popover.tsx @@ -10,6 +10,7 @@ import type { Placement, PopperElementProps, } from "@khanacademy/wonder-blocks-tooltip"; +import type {RootBoundary} from "@popperjs/core"; import PopoverContent from "./popover-content"; import PopoverContentCore from "./popover-content-core"; @@ -116,6 +117,13 @@ type Props = AriaProps & * your content does not get clipped or hidden. */ portal?: boolean; + /** + * Optional property to set what the root boundary is for the popper behavior. + * This is set to "viewport" by default, causing the popper to be positioned based + * on the user's viewport. If set to "document", it will position itself based + * on where there is available room within the document body. + */ + rootBoundary?: RootBoundary; }>; type State = Readonly<{ @@ -137,6 +145,7 @@ type DefaultProps = Readonly<{ placement: Props["placement"]; showTail: Props["showTail"]; portal: Props["portal"]; + rootBoundary: Props["rootBoundary"]; }>; /** @@ -167,6 +176,7 @@ export default class Popover extends React.Component { placement: "top", showTail: true, portal: true, + rootBoundary: "viewport", }; /** @@ -279,6 +289,7 @@ export default class Popover extends React.Component { portal, "aria-label": ariaLabel, "aria-describedby": ariaDescribedBy, + rootBoundary, } = this.props; const {anchorElement} = this.state; @@ -287,7 +298,11 @@ export default class Popover extends React.Component { const ariaLabelledBy = ariaLabel ? undefined : `${uniqueId}-title`; const popperContent = ( - + {(props: PopperElementProps) => ( >; + +type Modifiers = + | Partial + | Partial + | Partial; + +/** + * This function calculates the height of the popper + * vs. the height of the viewport. If the popper is larger + * than the viewport, it sets the popper isReferenceHidden + * state to false, to ensure the popper stays visible even if + * the reference is no longer in view. If the popper is less + * than the viewport, it leaves it as is so the popper will + * disappear if the reference is no longer in view. + */ +function _modifyPosition({ + state, +}: ModifierArguments>): void { + // Calculates the available space for the popper based on the placement + // relative to the viewport. + const popperHeight = + state.rects.popper.height + state.rects.reference.height; + const minHeight = document.documentElement.clientHeight; + + if (minHeight < popperHeight && state.modifiersData.hide) { + state.modifiersData.hide = { + ...state.modifiersData.hide, + isReferenceHidden: false, + }; + } +} + +const smallViewportModifier: SmallViewportModifier = { + name: "smallViewport", + enabled: true, + phase: "main", + fn: _modifyPosition, +}; + /** * A component that wraps react-popper's Popper component to provide a * consistent interface for positioning floating elements. */ export default class TooltipPopper extends React.Component { + static defaultProps: DefaultProps = { + rootBoundary: "viewport", + }; + /** * Automatically updates the position of the floating element when necessary * to ensure it stays anchored. @@ -172,21 +230,33 @@ export default class TooltipPopper extends React.Component { } render(): React.ReactNode { - const {anchorElement, placement} = this.props; + const {anchorElement, placement, rootBoundary} = this.props; + + // TODO(WB-1680): Use floating-ui's + const modifiers: Modifiers[] = [smallViewportModifier]; + + if (rootBoundary === "viewport") { + modifiers.push({ + name: "preventOverflow", + options: { + rootBoundary: "viewport", + }, + }); + } else { + modifiers.push({ + name: "flip", + options: { + rootBoundary: "document", + }, + }); + } return ( {(props) => this._renderPositionedContent(props)}