From 235a2033d7e492e5ed245e7a81b0082e8ecd35ac Mon Sep 17 00:00:00 2001 From: Elliott Marquez Date: Thu, 7 Sep 2023 10:35:35 -0700 Subject: [PATCH] feat(menu): menus will resize and flip corners to stay in viewport This is a change to the repositioning algorithm that attempts to keep the surface in the screen. This largely follows the behavior of Wiz's implementation. Instead of just repositioning the surface in the window, it will also attempt to flip the anchor and surface anchors and compares whether that will cause less repositioning of the menu. It also adds the option for it to resize the surface rather than just move it so that it doesn't overlap other components like md-select. PiperOrigin-RevId: 563475751 --- menu/internal/menu.ts | 13 +- menu/internal/surfacePositionController.ts | 249 +++++++++++++++++---- 2 files changed, 219 insertions(+), 43 deletions(-) diff --git a/menu/internal/menu.ts b/menu/internal/menu.ts index f402da9e69..89a277ba09 100644 --- a/menu/internal/menu.ts +++ b/menu/internal/menu.ts @@ -139,11 +139,17 @@ export abstract class Menu extends LitElement { /** * The corner of the anchor which to align the menu in the standard logical * property style of _. + * + * NOTE: This value may not be respected by the menu positioning algorithm + * if the menu would render outisde the viewport. */ @property({attribute: 'anchor-corner'}) anchorCorner: Corner = 'END_START'; /** * The corner of the menu which to align the anchor in the standard logical * property style of _. + * + * NOTE: This value may not be respected by the menu positioning algorithm + * if the menu would render outisde the viewport. */ @property({attribute: 'menu-corner'}) menuCorner: Corner = 'START_START'; /** @@ -225,7 +231,8 @@ export abstract class Menu extends LitElement { } /** - * Handles positioning the surface and aligning it to the anchor. + * Handles positioning the surface and aligning it to the anchor as well as + * keeping it in the viewport. */ private readonly menuPositionController = new SurfacePositionController(this, () => { @@ -241,6 +248,10 @@ export abstract class Menu extends LitElement { onOpen: this.onOpened, beforeClose: this.beforeClose, onClose: this.onClosed, + // We can't resize components that have overflow like menus with + // submenus because the overflow-y will show menu items / content + // outside the bounds of the menu. (to be fixed w/ popover API) + repositionStrategy: this.hasOverflow ? 'move' : 'resize', }; }); diff --git a/menu/internal/surfacePositionController.ts b/menu/internal/surfacePositionController.ts index e00c9f0202..419f92aa08 100644 --- a/menu/internal/surfacePositionController.ts +++ b/menu/internal/surfacePositionController.ts @@ -72,6 +72,14 @@ export interface SurfacePositionControllerProperties { * Positive is down and negative is up. */ yOffset: number; + /** + * The strategy to follow when repositioning the menu to stay inside the + * viewport. "move" will simply move the surface to stay in the viewport. + * "resize" will attempt to resize the surface. + * + * Both strategies will still attempt to flip the anchor and surface corners. + */ + repositionStrategy: 'move'|'resize'; /** * A function to call after the surface has been positioned. */ @@ -134,9 +142,10 @@ export class SurfacePositionController implements ReactiveController { anchorEl, anchorCorner: anchorCornerRaw, surfaceCorner: surfaceCornerRaw, - isTopLayer: topLayerRaw, + isTopLayer, xOffset, yOffset, + repositionStrategy, } = this.getProperties(); const anchorCorner = anchorCornerRaw.toUpperCase().trim(); const surfaceCorner = surfaceCornerRaw.toUpperCase().trim(); @@ -167,20 +176,9 @@ export class SurfacePositionController implements ReactiveController { const [anchorBlock, anchorInline] = anchorCorner.split('_') as Array<'START'|'END'>; - - // We use number booleans to multiply values rather than `if` / ternary - // statements because it _heavily_ cuts down on nesting and readability - const isTopLayer = topLayerRaw ? 1 : 0; // LTR depends on the direction of the SURFACE not the anchor. const isLTR = - getComputedStyle(surfaceEl as HTMLElement).direction === 'ltr' ? 1 : 0; - const isRTL = isLTR ? 0 : 1; - const isSurfaceInlineStart = surfaceInline === 'START' ? 1 : 0; - const isSurfaceInlineEnd = surfaceInline === 'END' ? 1 : 0; - const isSurfaceBlockStart = surfaceBlock === 'START' ? 1 : 0; - const isSurfaceBlockEnd = surfaceBlock === 'END' ? 1 : 0; - const isOneInlineEnd = anchorInline !== surfaceInline ? 1 : 0; - const isOneBlockEnd = anchorBlock !== surfaceBlock ? 1 : 0; + getComputedStyle(surfaceEl as HTMLElement).direction === 'ltr'; /* * A diagram that helps describe some of the variables used in the following @@ -214,6 +212,193 @@ export class SurfacePositionController implements ReactiveController { * └────┘ */ + // Calculate the block positioning properties + let {blockInset, blockOutOfBoundsCorrection, surfaceBlockProperty} = + this.calculateBlock({ + surfaceRect, + anchorRect, + anchorBlock, + surfaceBlock, + yOffset, + isTopLayer + }); + + // If the surface should be out of bounds in the block direction, flip the + // surface and anchor corner block values and recalculate + if (blockOutOfBoundsCorrection) { + const flippedSurfaceBlock = surfaceBlock === 'START' ? 'END' : 'START'; + const flippedAnchorBlock = anchorBlock === 'START' ? 'END' : 'START'; + + const flippedBlock = this.calculateBlock({ + surfaceRect, + anchorRect, + anchorBlock: flippedAnchorBlock, + surfaceBlock: flippedSurfaceBlock, + yOffset, + isTopLayer + }); + + // In the case that the flipped verion would require less out of bounds + // correcting, use the flipped corner block values + if (blockOutOfBoundsCorrection > + flippedBlock.blockOutOfBoundsCorrection) { + blockInset = flippedBlock.blockInset; + blockOutOfBoundsCorrection = flippedBlock.blockOutOfBoundsCorrection; + surfaceBlockProperty = flippedBlock.surfaceBlockProperty; + } + } + + // Calculate the inline positioning properties + let {inlineInset, inlineOutOfBoundsCorrection, surfaceInlineProperty} = + this.calculateInline({ + surfaceRect, + anchorRect, + anchorInline, + surfaceInline, + xOffset, + isTopLayer, + isLTR, + }); + + // If the surface should be out of bounds in the inline direction, flip the + // surface and anchor corner inline values and recalculate + if (inlineOutOfBoundsCorrection) { + const flippedSurfaceInline = surfaceInline === 'START' ? 'END' : 'START'; + const flippedAnchorInline = anchorInline === 'START' ? 'END' : 'START'; + + const flippedInline = this.calculateInline({ + surfaceRect, + anchorRect, + anchorInline: flippedAnchorInline, + surfaceInline: flippedSurfaceInline, + xOffset, + isTopLayer, + isLTR, + }); + + // In the case that the flipped verion would require less out of bounds + // correcting, use the flipped corner inline values + if (Math.abs(inlineOutOfBoundsCorrection) > + Math.abs(flippedInline.inlineOutOfBoundsCorrection)) { + inlineInset = flippedInline.inlineInset; + inlineOutOfBoundsCorrection = flippedInline.inlineOutOfBoundsCorrection; + surfaceInlineProperty = flippedInline.surfaceInlineProperty; + } + } + + // If we are simply repositioning the surface back inside the viewport, + // subtract the out of bounds correction values from the positioning. + if (repositionStrategy === 'move') { + blockInset = blockInset - blockOutOfBoundsCorrection; + inlineInset = inlineInset - inlineOutOfBoundsCorrection; + } + + this.surfaceStylesInternal = { + 'display': 'block', + 'opacity': '1', + [surfaceBlockProperty]: `${blockInset}px`, + [surfaceInlineProperty]: `${inlineInset}px`, + }; + + // In the case that we are resizing the surface to stay inside the viewport + // we need to set height and width on the surface. + if (repositionStrategy === 'resize') { + // Add a height property to the styles if there is block height correction + if (blockOutOfBoundsCorrection) { + this.surfaceStylesInternal['height'] = + `${surfaceRect.height - blockOutOfBoundsCorrection}px`; + } + + // Add a width property to the styles if there is block height correction + if (inlineOutOfBoundsCorrection) { + this.surfaceStylesInternal['width'] = + `${surfaceRect.width - inlineOutOfBoundsCorrection}px`; + } + } + + this.host.requestUpdate(); + } + + /** + * Calculates the css property, the inset, and the out of bounds correction + * for the surface in the block direction. + */ + private calculateBlock(config: { + surfaceRect: DOMRect, + anchorRect: DOMRect, + anchorBlock: 'START'|'END', + surfaceBlock: 'START'|'END', + yOffset: number, + isTopLayer: boolean, + }) { + const { + surfaceRect, + anchorRect, + anchorBlock, + surfaceBlock, + yOffset, + isTopLayer: isTopLayerBool, + } = config; + // We use number booleans to multiply values rather than `if` / ternary + // statements because it _heavily_ cuts down on nesting and readability + const isTopLayer = isTopLayerBool ? 1 : 0; + const isSurfaceBlockStart = surfaceBlock === 'START' ? 1 : 0; + const isSurfaceBlockEnd = surfaceBlock === 'END' ? 1 : 0; + const isOneBlockEnd = anchorBlock !== surfaceBlock ? 1 : 0; + + // Whether or not to apply the height of the anchor + const blockAnchorOffset = isOneBlockEnd * anchorRect.height + yOffset; + // The absolute block position of the anchor relative to window + const blockTopLayerOffset = isSurfaceBlockStart * anchorRect.top + + isSurfaceBlockEnd * (window.innerHeight - anchorRect.bottom); + // If the surface's block would be out of bounds of the window, move it back + // in + const blockOutOfBoundsCorrection = Math.abs(Math.min( + 0, + window.innerHeight - blockTopLayerOffset - blockAnchorOffset - + surfaceRect.height)); + + + // The block logical value of the surface + const blockInset = isTopLayer * blockTopLayerOffset + blockAnchorOffset; + + const surfaceBlockProperty = + surfaceBlock === 'START' ? 'inset-block-start' : 'inset-block-end'; + + return {blockInset, blockOutOfBoundsCorrection, surfaceBlockProperty}; + } + + /** + * Calculates the css property, the inset, and the out of bounds correction + * for the surface in the inline direction. + */ + private calculateInline(config: { + isLTR: boolean, + surfaceInline: 'START'|'END', + anchorInline: 'START'|'END', + anchorRect: DOMRect, + surfaceRect: DOMRect, + xOffset: number, + isTopLayer: boolean, + }) { + const { + isLTR: isLTRBool, + surfaceInline, + anchorInline, + anchorRect, + surfaceRect, + xOffset, + isTopLayer: isTopLayerBool, + } = config; + // We use number booleans to multiply values rather than `if` / ternary + // statements because it _heavily_ cuts down on nesting and readability + const isTopLayer = isTopLayerBool ? 1 : 0; + const isLTR = isLTRBool ? 1 : 0; + const isRTL = isLTRBool ? 0 : 1; + const isSurfaceInlineStart = surfaceInline === 'START' ? 1 : 0; + const isSurfaceInlineEnd = surfaceInline === 'END' ? 1 : 0; + const isOneInlineEnd = anchorInline !== surfaceInline ? 1 : 0; + // Whether or not to apply the width of the anchor const inlineAnchorOffset = isOneInlineEnd * anchorRect.width + xOffset; // The inline position of the anchor relative to window in LTR @@ -226,46 +411,26 @@ export class SurfacePositionController implements ReactiveController { // The inline position of the anchor relative to window const inlineTopLayerOffset = isLTR * inlineTopLayerOffsetLTR + isRTL * inlineTopLayerOffsetRTL; + // If the surface's inline would be out of bounds of the window, move it // back in - const inlineOutOfBoundsCorrection = Math.min( + const inlineOutOfBoundsCorrection = Math.abs(Math.min( 0, window.innerWidth - inlineTopLayerOffset - inlineAnchorOffset - - surfaceRect.width); - - // The inline logical value of the surface - const inline = isTopLayer * inlineTopLayerOffset + inlineAnchorOffset + - inlineOutOfBoundsCorrection; + surfaceRect.width)); - // Whether or not to apply the height of the anchor - const blockAnchorOffset = isOneBlockEnd * anchorRect.height + yOffset; - // The absolute block position of the anchor relative to window - const blockTopLayerOffset = isSurfaceBlockStart * anchorRect.top + - isSurfaceBlockEnd * (window.innerHeight - anchorRect.bottom); - // If the surface's block would be out of bounds of the window, move it back - // in - const blockOutOfBoundsCorrection = Math.min( - 0, - window.innerHeight - blockTopLayerOffset - blockAnchorOffset - - surfaceRect.height); - // The block logical value of the surface - const block = isTopLayer * blockTopLayerOffset + blockAnchorOffset + - blockOutOfBoundsCorrection; + // The inline logical value of the surface + const inlineInset = isTopLayer * inlineTopLayerOffset + inlineAnchorOffset; - const surfaceBlockProperty = - surfaceBlock === 'START' ? 'inset-block-start' : 'inset-block-end'; const surfaceInlineProperty = surfaceInline === 'START' ? 'inset-inline-start' : 'inset-inline-end'; - this.surfaceStylesInternal = { - 'display': 'block', - 'opacity': '1', - [surfaceBlockProperty]: `${block}px`, - [surfaceInlineProperty]: `${inline}px`, + return { + inlineInset, + inlineOutOfBoundsCorrection, + surfaceInlineProperty, }; - - this.host.requestUpdate(); } hostUpdate() {