UNPKG

@material/web

Version:
394 lines 20.2 kB
/** * @license * Copyright 2023 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /** * An enum of supported Menu corners */ // tslint:disable-next-line:enforce-name-casing We are mimicking enum style export const Corner = { END_START: 'end-start', END_END: 'end-end', START_START: 'start-start', START_END: 'start-end', }; /** * Given a surface, an anchor, corners, and some options, this surface will * calculate the position of a surface to align the two given corners and keep * the surface inside the window viewport. It also provides a StyleInfo map that * can be applied to the surface to handle visiblility and position. */ export class SurfacePositionController { /** * @param host The host to connect the controller to. * @param getProperties A function that returns the properties for the * controller. */ constructor(host, getProperties) { this.host = host; this.getProperties = getProperties; // The current styles to apply to the surface. this.surfaceStylesInternal = { 'display': 'none', }; // Previous values stored for change detection. Open change detection is // calculated separately so initialize it here. this.lastValues = { isOpen: false, }; this.host.addController(this); } /** * The StyleInfo map to apply to the surface via Lit's stylemap */ get surfaceStyles() { return this.surfaceStylesInternal; } /** * Calculates the surface's new position required so that the surface's * `surfaceCorner` aligns to the anchor's `anchorCorner` while keeping the * surface inside the window viewport. This positioning also respects RTL by * checking `getComputedStyle()` on the surface element. */ async position() { const { surfaceEl, anchorEl, anchorCorner: anchorCornerRaw, surfaceCorner: surfaceCornerRaw, positioning, xOffset, yOffset, disableBlockFlip, disableInlineFlip, repositionStrategy, } = this.getProperties(); const anchorCorner = anchorCornerRaw.toLowerCase().trim(); const surfaceCorner = surfaceCornerRaw.toLowerCase().trim(); if (!surfaceEl || !anchorEl) { return; } // Store these before we potentially resize the window with the next set of // lines const windowInnerWidth = window.innerWidth; const windowInnerHeight = window.innerHeight; const div = document.createElement('div'); div.style.opacity = '0'; div.style.position = 'fixed'; div.style.display = 'block'; div.style.inset = '0'; document.body.appendChild(div); const scrollbarTestRect = div.getBoundingClientRect(); div.remove(); // Calculate the widths of the scrollbars in the inline and block directions // to account for window-relative calculations. const blockScrollbarHeight = window.innerHeight - scrollbarTestRect.bottom; const inlineScrollbarWidth = window.innerWidth - scrollbarTestRect.right; // Paint the surface transparently so that we can get the position and the // rect info of the surface. this.surfaceStylesInternal = { 'display': 'block', 'opacity': '0', }; // Wait for it to be visible. this.host.requestUpdate(); await this.host.updateComplete; // Safari has a bug that makes popovers render incorrectly if the node is // made visible + Animation Frame before calling showPopover(). // https://bugs.webkit.org/show_bug.cgi?id=264069 // also the cast is required due to differing TS types in Google and OSS. if (surfaceEl.popover && surfaceEl.isConnected) { surfaceEl.showPopover(); } const surfaceRect = surfaceEl.getSurfacePositionClientRect ? surfaceEl.getSurfacePositionClientRect() : surfaceEl.getBoundingClientRect(); const anchorRect = anchorEl.getSurfacePositionClientRect ? anchorEl.getSurfacePositionClientRect() : anchorEl.getBoundingClientRect(); const [surfaceBlock, surfaceInline] = surfaceCorner.split('-'); const [anchorBlock, anchorInline] = anchorCorner.split('-'); // LTR depends on the direction of the SURFACE not the anchor. const isLTR = getComputedStyle(surfaceEl).direction === 'ltr'; /* * For more on inline and block dimensions, see MDN article: * https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_logical_properties_and_values * * ┌───── inline/blockDocumentOffset inlineScrollbarWidth * │ │ │ * │ ┌─▼─────┐ │Document * │ ┌┼───────┴──────────────────────────────┼────────┐ * │ ││ │ │ * └──► ││ ┌───── inline/blockWindowOffset │ │ * ││ │ │ ▼ │ * ││ │ ┌─▼───┐ Window┌┐ │ * └┤ │ ┌┼─────┴───────────────────────┼│ │ * │ │ ││ ││ │ * │ └──► ││ ┌──inline/blockAnchorOffset││ │ * │ ││ │ │ ││ │ * │ └┤ │ ┌──▼───┐ ││ │ * │ │ │ ┌┼──────┤ ││ │ * │ │ └─►│Anchor│ ││ │ * │ │ └┴──────┘ ││ │ * │ │ ││ │ * │ │ ┌───────────────────────┼┼────┐ │ * │ │ │ Surface ││ │ │ * │ │ │ ││ │ │ * │ │ │ ││ │ │ * │ │ │ ││ │ │ * │ │ │ ││ │ │ * │ ┌┼─────┼───────────────────────┼│ │ │ * │ ┌─►┴──────┼────────────────────────┘ ├┐ │ * │ │ │ inline/blockOOBCorrection ││ │ * │ │ │ │ ││ │ * │ │ │ ├──►├│ │ * │ │ │ │ ││ │ * │ │ └────────────────────────┐▼───┼┘ │ * │ blockScrollbarHeight └────┘ │ * │ │ * └───────────────────────────────────────────────┘ */ // Calculate the block positioning properties let { blockInset, blockOutOfBoundsCorrection, surfaceBlockProperty } = this.calculateBlock({ surfaceRect, anchorRect, anchorBlock, surfaceBlock, yOffset, positioning, windowInnerHeight, blockScrollbarHeight, }); // If the surface should be out of bounds in the block direction, flip the // surface and anchor corner block values and recalculate if (blockOutOfBoundsCorrection && !disableBlockFlip) { const flippedSurfaceBlock = surfaceBlock === 'start' ? 'end' : 'start'; const flippedAnchorBlock = anchorBlock === 'start' ? 'end' : 'start'; const flippedBlock = this.calculateBlock({ surfaceRect, anchorRect, anchorBlock: flippedAnchorBlock, surfaceBlock: flippedSurfaceBlock, yOffset, positioning, windowInnerHeight, blockScrollbarHeight, }); // 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, positioning, isLTR, windowInnerWidth, inlineScrollbarWidth, }); // If the surface should be out of bounds in the inline direction, flip the // surface and anchor corner inline values and recalculate if (inlineOutOfBoundsCorrection && !disableInlineFlip) { const flippedSurfaceInline = surfaceInline === 'start' ? 'end' : 'start'; const flippedAnchorInline = anchorInline === 'start' ? 'end' : 'start'; const flippedInline = this.calculateInline({ surfaceRect, anchorRect, anchorInline: flippedAnchorInline, surfaceInline: flippedSurfaceInline, xOffset, positioning, isLTR, windowInnerWidth, inlineScrollbarWidth, }); // 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. */ calculateBlock(config) { const { surfaceRect, anchorRect, anchorBlock, surfaceBlock, yOffset, positioning, windowInnerHeight, blockScrollbarHeight, } = config; // We use number booleans to multiply values rather than `if` / ternary // statements because it _heavily_ cuts down on nesting and readability const relativeToWindow = positioning === 'fixed' || positioning === 'document' ? 1 : 0; const relativeToDocument = positioning === 'document' ? 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 * (windowInnerHeight - anchorRect.bottom - blockScrollbarHeight); const blockDocumentOffset = isSurfaceBlockStart * window.scrollY - isSurfaceBlockEnd * window.scrollY; // If the surface's block would be out of bounds of the window, move it back // in const blockOutOfBoundsCorrection = Math.abs(Math.min(0, windowInnerHeight - blockTopLayerOffset - blockAnchorOffset - surfaceRect.height)); // The block logical value of the surface const blockInset = relativeToWindow * blockTopLayerOffset + relativeToDocument * blockDocumentOffset + 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. */ calculateInline(config) { const { isLTR: isLTRBool, surfaceInline, anchorInline, anchorRect, surfaceRect, xOffset, positioning, windowInnerWidth, inlineScrollbarWidth, } = config; // We use number booleans to multiply values rather than `if` / ternary // statements because it _heavily_ cuts down on nesting and readability const relativeToWindow = positioning === 'fixed' || positioning === 'document' ? 1 : 0; const relativeToDocument = positioning === 'document' ? 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 const inlineTopLayerOffsetLTR = isSurfaceInlineStart * anchorRect.left + isSurfaceInlineEnd * (windowInnerWidth - anchorRect.right - inlineScrollbarWidth); // The inline position of the anchor relative to window in RTL const inlineTopLayerOffsetRTL = isSurfaceInlineStart * (windowInnerWidth - anchorRect.right - inlineScrollbarWidth) + isSurfaceInlineEnd * anchorRect.left; // The inline position of the anchor relative to window const inlineTopLayerOffset = isLTR * inlineTopLayerOffsetLTR + isRTL * inlineTopLayerOffsetRTL; // The inline position of the anchor relative to window in LTR const inlineDocumentOffsetLTR = isSurfaceInlineStart * window.scrollX - isSurfaceInlineEnd * window.scrollX; // The inline position of the anchor relative to window in RTL const inlineDocumentOffsetRTL = isSurfaceInlineEnd * window.scrollX - isSurfaceInlineStart * window.scrollX; // The inline position of the anchor relative to window const inlineDocumentOffset = isLTR * inlineDocumentOffsetLTR + isRTL * inlineDocumentOffsetRTL; // If the surface's inline would be out of bounds of the window, move it // back in const inlineOutOfBoundsCorrection = Math.abs(Math.min(0, windowInnerWidth - inlineTopLayerOffset - inlineAnchorOffset - surfaceRect.width)); // The inline logical value of the surface const inlineInset = relativeToWindow * inlineTopLayerOffset + inlineAnchorOffset + relativeToDocument * inlineDocumentOffset; let surfaceInlineProperty = surfaceInline === 'start' ? 'inset-inline-start' : 'inset-inline-end'; // There are cases where the element is RTL but the root of the page is not. // In these cases we want to not use logical properties. if (positioning === 'document' || positioning === 'fixed') { if ((surfaceInline === 'start' && isLTRBool) || (surfaceInline === 'end' && !isLTRBool)) { surfaceInlineProperty = 'left'; } else { surfaceInlineProperty = 'right'; } } return { inlineInset, inlineOutOfBoundsCorrection, surfaceInlineProperty, }; } hostUpdate() { this.onUpdate(); } hostUpdated() { this.onUpdate(); } /** * Checks whether the properties passed into the controller have changed since * the last positioning. If so, it will reposition if the surface is open or * close it if the surface should close. */ async onUpdate() { const props = this.getProperties(); let hasChanged = false; for (const [key, value] of Object.entries(props)) { // tslint:disable-next-line hasChanged = hasChanged || value !== this.lastValues[key]; if (hasChanged) break; } const openChanged = this.lastValues.isOpen !== props.isOpen; const hasAnchor = !!props.anchorEl; const hasSurface = !!props.surfaceEl; if (hasChanged && hasAnchor && hasSurface) { // Only update isOpen, because if it's closed, we do not want to waste // time on a useless reposition calculation. So save the other "dirty" // values until next time it opens. this.lastValues.isOpen = props.isOpen; if (props.isOpen) { // We are going to do a reposition, so save the prop values for future // dirty checking. this.lastValues = props; await this.position(); props.onOpen(); } else if (openChanged) { await props.beforeClose(); this.close(); props.onClose(); } } } /** * Hides the surface. */ close() { this.surfaceStylesInternal = { 'display': 'none', }; this.host.requestUpdate(); const surfaceEl = this.getProperties().surfaceEl; // The following type casts are required due to differing TS types in Google // and open source. if (surfaceEl?.popover && surfaceEl?.isConnected) { surfaceEl.hidePopover(); } } } //# sourceMappingURL=surfacePositionController.js.map