diff --git a/compat/base/utils.ts b/compat/base/utils.ts new file mode 100644 index 0000000000..435a5fd8f5 --- /dev/null +++ b/compat/base/utils.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export const deepActiveElementPath = (doc = window.document): Element[] => { + let activeElement = doc.activeElement; + const path: Element[] = []; + + if (!activeElement) { + return path; + } + + while (activeElement) { + path.push(activeElement); + if (activeElement.shadowRoot) { + activeElement = activeElement.shadowRoot.activeElement; + } else { + break; + } + } + + return path; +}; + +export const doesElementContainFocus = (element: HTMLElement): boolean => { + const activePath = deepActiveElementPath(); + + if (!activePath.length) { + return false; + } + + const deepActiveElement = activePath[activePath.length - 1]; + const focusEv = + new Event('check-if-focused', {bubbles: true, composed: true}); + let composedPath: EventTarget[] = []; + const listener = (ev: Event) => { + composedPath = ev.composedPath(); + }; + + document.body.addEventListener('check-if-focused', listener); + deepActiveElement.dispatchEvent(focusEv); + document.body.removeEventListener('check-if-focused', listener); + + return composedPath.indexOf(element) !== -1; +}; diff --git a/menusurface/lib/_mixins.scss b/menusurface/lib/_mixins.scss new file mode 100644 index 0000000000..0f78037f10 --- /dev/null +++ b/menusurface/lib/_mixins.scss @@ -0,0 +1,119 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// stylelint-disable selector-class-pattern -- +// Selector '.md3-*' should only be used in this project. + +$fade-in-duration: 0.03s !default; +$fade-out-duration: 0.075s !default; +$scale-duration: 0.12s !default; +$min-distance-from-edge: 32px !default; +$z-index: 8 !default; // One above mdc-dialog +$shape-radius: medium !default; +$deceleration-curve-timing-function: cubic-bezier(0, 0, 0.2, 1) !default; + +@mixin core-styles() { + // postcss-bem-linter: define menu-surface + .md3-menu-surface { + @include base_(); + // TODO(b/239422022): Remove in favor of theming API. + // @include elevation-mixins.elevation($z-value: 8); + @include fill-color(surface); + @include ink-color(on-surface); + @include shape-radius($shape-radius); + + // TODO(b/239421773): Fix this for RTL. + transform-origin: top left; + } + + .md3-menu-surface--anchor { + position: relative; + overflow: visible; + } + + .md3-menu-surface--fixed { + position: fixed; + } + + .md3-menu-surface--fullwidth { + width: 100%; + } + // postcss-bem-linter: end +} + +@mixin ink-color($color) { + color: $color; +} + +@mixin fill-color($color) { + background-color: $color; +} + +@mixin shape-radius($radius) { + border-radius: $radius; +} + +// Used by filled variants of GM components to conditionally flatten the top +// corners of the menu. +@mixin flatten-top-when-opened-below() { + .md3-menu-surface--is-open-below { + border-top-left-radius: 0px; + border-top-right-radius: 0px; + } +} + +// +// Private +// + +@mixin base_() { + display: none; + position: absolute; + box-sizing: border-box; + + $max-width: calc(100vw - #{$min-distance-from-edge}); + $max-height: calc(100vh - #{$min-distance-from-edge}); + + max-width: $max-width; + max-height: $max-height; + margin: 0; + padding: 0; + transform: scale(1); + transform-origin: top left; + opacity: 0; + overflow: auto; + will-change: transform, opacity; + z-index: $z-index; + + transition: opacity $fade-in-duration linear, + transform $scale-duration $deceleration-curve-timing-function, + height 250ms $deceleration-curve-timing-function; + + &:focus { + outline: none; + } + + &--animating-open { + display: inline-block; + transform: scale(0.8); + opacity: 0; + } + + // Render this after `--animating-open` to override `opacity` & `transform` + // CSS properties. + &--open { + display: inline-block; + transform: scale(1); + opacity: 1; + } + + &--animating-closed { + display: inline-block; + opacity: 0; + + transition: opacity $fade-out-duration linear; + } +} diff --git a/menusurface/lib/adapter.ts b/menusurface/lib/adapter.ts new file mode 100644 index 0000000000..74d13fbd6c --- /dev/null +++ b/menusurface/lib/adapter.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {MDCMenuDimensions, MDCMenuDistance, MDCMenuPoint} from './types'; + +/** + * Defines the shape of the adapter expected by the foundation. + * Implement this adapter for your framework of choice to delegate updates to + * the component in your framework of choice. See architecture documentation + * for more details. + * https://github.com/material-components/material-components-web/blob/master/docs/code/architecture.md + */ +export interface MDCMenuSurfaceAdapter { + addClass(className: string): void; + removeClass(className: string): void; + hasClass(className: string): boolean; + hasAnchor(): boolean; + + isElementInContainer(el: Element): boolean; + isFocused(): boolean; + isRtl(): boolean; + + getInnerDimensions(): MDCMenuDimensions; + getAnchorDimensions(): DOMRect|null; + getWindowDimensions(): MDCMenuDimensions; + getBodyDimensions(): MDCMenuDimensions; + getWindowScroll(): MDCMenuPoint; + setPosition(position: Partial): void; + setMaxHeight(height: string): void; + setTransformOrigin(origin: string): void; + getOwnerDocument?(): Document; + + /** Saves the element that was focused before the menu surface was opened. */ + saveFocus(): void; + + /** + * Restores focus to the element that was focused before the menu surface was + * opened. + */ + restoreFocus(): void; + + /** Emits an event when the menu surface is closed. */ + notifyClose(): void; + + /** Emits an event when the menu surface is closing. */ + notifyClosing(): void; + + /** Emits an event when the menu surface is opened. */ + notifyOpen(): void; + + /** Emits an event when the menu surface is opening. */ + notifyOpening(): void; +} diff --git a/menusurface/lib/constants.ts b/menusurface/lib/constants.ts new file mode 100644 index 0000000000..e58fc34b66 --- /dev/null +++ b/menusurface/lib/constants.ts @@ -0,0 +1,94 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +const cssClasses = { + ANCHOR: 'md3-menu-surface--anchor', + ANIMATING_CLOSED: 'md3-menu-surface--animating-closed', + ANIMATING_OPEN: 'md3-menu-surface--animating-open', + FIXED: 'md3-menu-surface--fixed', + IS_OPEN_BELOW: 'md3-menu-surface--is-open-below', + OPEN: 'md3-menu-surface--open', + ROOT: 'md3-menu-surface', +}; + +// tslint:disable:object-literal-sort-keys +const strings = { + CLOSED_EVENT: 'MDCMenuSurface:closed', + CLOSING_EVENT: 'MDCMenuSurface:closing', + OPENED_EVENT: 'MDCMenuSurface:opened', + OPENING_EVENT: 'MDCMenuSurface:opening', + FOCUSABLE_ELEMENTS: [ + 'button:not(:disabled)', + '[href]:not([aria-disabled="true"])', + 'input:not(:disabled)', + 'select:not(:disabled)', + 'textarea:not(:disabled)', + '[tabindex]:not([tabindex="-1"]):not([aria-disabled="true"])', + ].join(', '), +}; +// tslint:enable:object-literal-sort-keys + +const numbers = { + /** Total duration of menu-surface open animation. */ + TRANSITION_OPEN_DURATION: 120, + + /** Total duration of menu-surface close animation. */ + TRANSITION_CLOSE_DURATION: 75, + + /** + * Margin left to the edge of the viewport when menu-surface is at maximum + * possible height. Also used as a viewport margin. + */ + MARGIN_TO_EDGE: 32, + + /** + * Ratio of anchor width to menu-surface width for switching from corner + * positioning to center positioning. + */ + ANCHOR_TO_MENU_SURFACE_WIDTH_RATIO: 0.67, + + /** + * Amount of time to wait before restoring focus when closing the menu + * surface. This is important because if a touch event triggered the menu + * close, and the subsequent mouse event occurs after focus is restored, then + * the restored focus would be lost. + */ + TOUCH_EVENT_WAIT_MS: 30, +}; + +/** + * Enum for bits in the {@see Corner) bitmap. + */ +enum CornerBit { + BOTTOM = 1, + CENTER = 2, + RIGHT = 4, + FLIP_RTL = 8, +} + +/** + * Enum for representing an element corner for positioning the menu-surface. + * + * The START constants map to LEFT if element directionality is left + * to right and RIGHT if the directionality is right to left. + * Likewise END maps to RIGHT or LEFT depending on the directionality. + */ +enum Corner { + TOP_LEFT = 0, + TOP_RIGHT = CornerBit.RIGHT, + BOTTOM_LEFT = CornerBit.BOTTOM, + BOTTOM_RIGHT = + CornerBit.BOTTOM | CornerBit.RIGHT, // tslint:disable-line:no-bitwise + TOP_START = CornerBit.FLIP_RTL, + TOP_END = + CornerBit.FLIP_RTL | CornerBit.RIGHT, // tslint:disable-line:no-bitwise + BOTTOM_START = + CornerBit.BOTTOM | CornerBit.FLIP_RTL, // tslint:disable-line:no-bitwise + BOTTOM_END = CornerBit.BOTTOM | CornerBit.RIGHT | + CornerBit.FLIP_RTL, // tslint:disable-line:no-bitwise +} + +export {cssClasses, strings, numbers, CornerBit, Corner}; diff --git a/menusurface/lib/foundation.ts b/menusurface/lib/foundation.ts new file mode 100644 index 0000000000..84c9aaa6aa --- /dev/null +++ b/menusurface/lib/foundation.ts @@ -0,0 +1,659 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {MDCMenuSurfaceAdapter} from './adapter'; +import {Corner, CornerBit, cssClasses, numbers, strings} from './constants'; +import {MDCMenuDimensions, MDCMenuDistance, MDCMenuPoint} from './types'; + +interface AutoLayoutMeasurements { + anchorSize: MDCMenuDimensions; + bodySize: MDCMenuDimensions; + surfaceSize: MDCMenuDimensions; + viewportDistance: MDCMenuDistance; + viewportSize: MDCMenuDimensions; + windowScroll: MDCMenuPoint; +} + +export class MDCMenuSurfaceFoundation { + static get cssClasses() { + return cssClasses; + } + + static get strings() { + return strings; + } + + static get numbers() { + return numbers; + } + + static get Corner() { + return Corner; + } + + /** + * @see {@link MDCMenuSurfaceAdapter} for typing information on parameters and return types. + */ + static get defaultAdapter(): MDCMenuSurfaceAdapter { + // tslint:disable:object-literal-sort-keys Methods should be in the same order as the adapter interface. + return { + addClass: () => undefined, + removeClass: () => undefined, + hasClass: () => false, + hasAnchor: () => false, + + isElementInContainer: () => false, + isFocused: () => false, + isRtl: () => false, + + getInnerDimensions: () => ({height: 0, width: 0}), + getAnchorDimensions: () => null, + getWindowDimensions: () => ({height: 0, width: 0}), + getBodyDimensions: () => ({height: 0, width: 0}), + getWindowScroll: () => ({x: 0, y: 0}), + setPosition: () => undefined, + setMaxHeight: () => undefined, + setTransformOrigin: () => undefined, + + saveFocus: () => undefined, + restoreFocus: () => undefined, + + notifyClose: () => undefined, + notifyClosing: () => undefined, + notifyOpen: () => undefined, + notifyOpening: () => undefined, + }; + // tslint:enable:object-literal-sort-keys + } + + private readonly adapter: MDCMenuSurfaceAdapter; + private isSurfaceOpen = false; + private isQuickOpen = false; + private isHoistedElement = false; + private isFixedPosition = false; + private isHorizontallyCenteredOnViewport = false; + + private maxHeight = 0; + private openBottomBias = 0; + + private openAnimationEndTimerId = 0; + private closeAnimationEndTimerId = 0; + private animationRequestId = 0; + + private anchorCorner: Corner = Corner.TOP_START; + + /** + * Corner of the menu surface to which menu surface is attached to anchor. + * + * Anchor corner --->+----------+ + * | ANCHOR | + * +----------+ + * Origin corner --->+--------------+ + * | | + * | | + * | MENU SURFACE | + * | | + * | | + * +--------------+ + */ + private originCorner: Corner = Corner.TOP_START; + private readonly anchorMargin: + MDCMenuDistance = {top: 0, right: 0, bottom: 0, left: 0}; + private readonly position: MDCMenuPoint = {x: 0, y: 0}; + + private dimensions!: MDCMenuDimensions; // assigned in open() + private measurements!: AutoLayoutMeasurements; // assigned in open() + + constructor(adapter: Partial) { + this.adapter = {...MDCMenuSurfaceFoundation.defaultAdapter, ...adapter}; + } + + init() { + const {ROOT, OPEN} = MDCMenuSurfaceFoundation.cssClasses; + + if (!this.adapter.hasClass(ROOT)) { + throw new Error(`${ROOT} class required in root element.`); + } + + if (this.adapter.hasClass(OPEN)) { + this.isSurfaceOpen = true; + } + } + + destroy() { + clearTimeout(this.openAnimationEndTimerId); + clearTimeout(this.closeAnimationEndTimerId); + // Cancel any currently running animations. + cancelAnimationFrame(this.animationRequestId); + } + + /** + * @param corner Default anchor corner alignment of top-left menu surface + * corner. + */ + setAnchorCorner(corner: Corner) { + this.anchorCorner = corner; + } + + /** + * Flip menu corner horizontally. + */ + flipCornerHorizontally() { + this.originCorner = this.originCorner ^ CornerBit.RIGHT; + } + + /** + * @param margin Set of margin values from anchor. + */ + setAnchorMargin(margin: Partial) { + this.anchorMargin.top = margin.top || 0; + this.anchorMargin.right = margin.right || 0; + this.anchorMargin.bottom = margin.bottom || 0; + this.anchorMargin.left = margin.left || 0; + } + + /** Used to indicate if the menu-surface is hoisted to the body. */ + setIsHoisted(isHoisted: boolean) { + this.isHoistedElement = isHoisted; + } + + /** + * Used to set the menu-surface calculations based on a fixed position menu. + */ + setFixedPosition(isFixedPosition: boolean) { + this.isFixedPosition = isFixedPosition; + } + + /** + * @return Returns true if menu is in fixed (`position: fixed`) position. + */ + isFixed() { + return this.isFixedPosition; + } + + /** Sets the menu-surface position on the page. */ + setAbsolutePosition(x: number, y: number) { + this.position.x = this.isFinite(x) ? x : 0; + this.position.y = this.isFinite(y) ? y : 0; + } + + /** Sets whether menu-surface should be horizontally centered to viewport. */ + setIsHorizontallyCenteredOnViewport(isCentered: boolean) { + this.isHorizontallyCenteredOnViewport = isCentered; + } + + setQuickOpen(quickOpen: boolean) { + this.isQuickOpen = quickOpen; + } + + /** + * Sets maximum menu-surface height on open. + * @param maxHeight The desired max-height. Set to 0 (default) to + * automatically calculate max height based on available viewport space. + */ + setMaxHeight(maxHeight: number) { + this.maxHeight = maxHeight; + } + + /** + * Set to a positive integer to influence the menu to preferentially open + * below the anchor instead of above. + * @param bias A value of `x` simulates an extra `x` pixels of available space + * below the menu during positioning calculations. + */ + setOpenBottomBias(bias: number) { + this.openBottomBias = bias; + } + + isOpen() { + return this.isSurfaceOpen; + } + + /** + * Open the menu surface. + */ + open() { + if (this.isSurfaceOpen) { + return; + } + + this.adapter.notifyOpening(); + this.adapter.saveFocus(); + + if (this.isQuickOpen) { + this.isSurfaceOpen = true; + this.adapter.addClass(MDCMenuSurfaceFoundation.cssClasses.OPEN); + this.dimensions = this.adapter.getInnerDimensions(); + this.autoposition(); + this.adapter.notifyOpen(); + } else { + this.adapter.addClass(MDCMenuSurfaceFoundation.cssClasses.ANIMATING_OPEN); + this.animationRequestId = requestAnimationFrame(() => { + this.dimensions = this.adapter.getInnerDimensions(); + this.autoposition(); + this.adapter.addClass(MDCMenuSurfaceFoundation.cssClasses.OPEN); + this.openAnimationEndTimerId = setTimeout(() => { + this.openAnimationEndTimerId = 0; + this.adapter.removeClass( + MDCMenuSurfaceFoundation.cssClasses.ANIMATING_OPEN); + this.adapter.notifyOpen(); + }, numbers.TRANSITION_OPEN_DURATION); + }); + + this.isSurfaceOpen = true; + } + } + + /** + * Closes the menu surface. + */ + close(skipRestoreFocus = false) { + if (!this.isSurfaceOpen) { + return; + } + + this.adapter.notifyClosing(); + + if (this.isQuickOpen) { + this.isSurfaceOpen = false; + if (!skipRestoreFocus) { + this.maybeRestoreFocus(); + } + + this.adapter.removeClass(MDCMenuSurfaceFoundation.cssClasses.OPEN); + this.adapter.removeClass( + MDCMenuSurfaceFoundation.cssClasses.IS_OPEN_BELOW); + this.adapter.notifyClose(); + + return; + } + + this.adapter.addClass(MDCMenuSurfaceFoundation.cssClasses.ANIMATING_CLOSED); + requestAnimationFrame(() => { + this.adapter.removeClass(MDCMenuSurfaceFoundation.cssClasses.OPEN); + this.adapter.removeClass( + MDCMenuSurfaceFoundation.cssClasses.IS_OPEN_BELOW); + this.closeAnimationEndTimerId = setTimeout(() => { + this.closeAnimationEndTimerId = 0; + this.adapter.removeClass( + MDCMenuSurfaceFoundation.cssClasses.ANIMATING_CLOSED); + this.adapter.notifyClose(); + }, numbers.TRANSITION_CLOSE_DURATION); + }); + + this.isSurfaceOpen = false; + if (!skipRestoreFocus) { + this.maybeRestoreFocus(); + } + } + + /** Handle clicks and close if not within menu-surface element. */ + handleBodyClick(evt: MouseEvent) { + const el = evt.target as Element; + if (this.adapter.isElementInContainer(el)) { + return; + } + this.close(); + } + + /** Handle keys that close the surface. */ + handleKeydown(evt: KeyboardEvent) { + const {keyCode, key} = evt; + + const isEscape = key === 'Escape' || keyCode === 27; + if (isEscape) { + this.close(); + } + } + + private autoposition() { + // Compute measurements for autoposition methods reuse. + this.measurements = this.getAutoLayoutmeasurements(); + + const corner = this.getoriginCorner(); + const maxMenuSurfaceHeight = this.getMenuSurfaceMaxHeight(corner); + const verticalAlignment = + this.hasBit(corner, CornerBit.BOTTOM) ? 'bottom' : 'top'; + let horizontalAlignment = + this.hasBit(corner, CornerBit.RIGHT) ? 'right' : 'left'; + const horizontalOffset = this.getHorizontalOriginOffset(corner); + const verticalOffset = this.getVerticalOriginOffset(corner); + const {anchorSize, surfaceSize} = this.measurements; + + const position: Partial = { + [horizontalAlignment]: horizontalOffset, + [verticalAlignment]: verticalOffset, + }; + + // Center align when anchor width is comparable or greater than menu + // surface, otherwise keep corner. + if (anchorSize.width / surfaceSize.width > + numbers.ANCHOR_TO_MENU_SURFACE_WIDTH_RATIO) { + horizontalAlignment = 'center'; + } + + // If the menu-surface has been hoisted to the body, it's no longer relative + // to the anchor element + if (this.isHoistedElement || this.isFixedPosition) { + this.adjustPositionForHoistedElement(position); + } + + this.adapter.setTransformOrigin( + `${horizontalAlignment} ${verticalAlignment}`); + this.adapter.setPosition(position); + this.adapter.setMaxHeight( + maxMenuSurfaceHeight ? maxMenuSurfaceHeight + 'px' : ''); + + // If it is opened from the top then add is-open-below class + if (!this.hasBit(corner, CornerBit.BOTTOM)) { + this.adapter.addClass(MDCMenuSurfaceFoundation.cssClasses.IS_OPEN_BELOW); + } + } + + /** + * @return Measurements used to position menu surface popup. + */ + private getAutoLayoutmeasurements(): AutoLayoutMeasurements { + let anchorRect = this.adapter.getAnchorDimensions(); + const bodySize = this.adapter.getBodyDimensions(); + const viewportSize = this.adapter.getWindowDimensions(); + const windowScroll = this.adapter.getWindowScroll(); + + if (!anchorRect) { + // tslint:disable:object-literal-sort-keys Positional properties are more readable when they're grouped together + anchorRect = { + top: this.position.y, + right: this.position.x, + bottom: this.position.y, + left: this.position.x, + width: 0, + height: 0, + } as any; + // tslint:enable:object-literal-sort-keys + } + + return { + anchorSize: anchorRect!, + bodySize, + surfaceSize: this.dimensions, + viewportDistance: { + // tslint:disable:object-literal-sort-keys Positional properties are more readable when they're grouped together + top: anchorRect!.top, + right: viewportSize.width - anchorRect!.right, + bottom: viewportSize.height - anchorRect!.bottom, + left: anchorRect!.left, + // tslint:enable:object-literal-sort-keys + }, + viewportSize, + windowScroll, + }; + } + + /** + * Computes the corner of the anchor from which to animate and position the + * menu surface. + * + * Only LEFT or RIGHT bit is used to position the menu surface ignoring RTL + * context. E.g., menu surface will be positioned from right side on TOP_END. + */ + private getoriginCorner(): Corner { + let corner = this.originCorner; + + const {viewportDistance, anchorSize, surfaceSize} = this.measurements; + const {MARGIN_TO_EDGE} = MDCMenuSurfaceFoundation.numbers; + + const isAnchoredToBottom = this.hasBit(this.anchorCorner, CornerBit.BOTTOM); + + let availableTop; + let availableBottom; + if (isAnchoredToBottom) { + availableTop = + viewportDistance.top - MARGIN_TO_EDGE + this.anchorMargin.bottom; + availableBottom = + viewportDistance.bottom - MARGIN_TO_EDGE - this.anchorMargin.bottom; + } else { + availableTop = + viewportDistance.top - MARGIN_TO_EDGE + this.anchorMargin.top; + availableBottom = viewportDistance.bottom - MARGIN_TO_EDGE + + anchorSize.height - this.anchorMargin.top; + } + + const isAvailableBottom = availableBottom - surfaceSize.height > 0; + if (!isAvailableBottom && + availableTop > availableBottom + this.openBottomBias) { + // Attach bottom side of surface to the anchor. + corner = this.setBit(corner, CornerBit.BOTTOM); + } + + const isRtl = this.adapter.isRtl(); + const isFlipRtl = this.hasBit(this.anchorCorner, CornerBit.FLIP_RTL); + const hasRightBit = this.hasBit(this.anchorCorner, CornerBit.RIGHT) || + this.hasBit(corner, CornerBit.RIGHT); + + // Whether surface attached to right side of anchor element. + let isAnchoredToRight = false; + + // Anchored to start + if (isRtl && isFlipRtl) { + isAnchoredToRight = !hasRightBit; + } else { + // Anchored to right + isAnchoredToRight = hasRightBit; + } + + let availableLeft; + let availableRight; + if (isAnchoredToRight) { + availableLeft = + viewportDistance.left + anchorSize.width + this.anchorMargin.right; + availableRight = viewportDistance.right - this.anchorMargin.right; + } else { + availableLeft = viewportDistance.left + this.anchorMargin.left; + availableRight = + viewportDistance.right + anchorSize.width - this.anchorMargin.left; + } + + const isAvailableLeft = availableLeft - surfaceSize.width > 0; + const isAvailableRight = availableRight - surfaceSize.width > 0; + const isOriginCornerAlignedToEnd = + this.hasBit(corner, CornerBit.FLIP_RTL) && + this.hasBit(corner, CornerBit.RIGHT); + + if (isAvailableRight && isOriginCornerAlignedToEnd && isRtl || + !isAvailableLeft && isOriginCornerAlignedToEnd) { + // Attach left side of surface to the anchor. + corner = this.unsetBit(corner, CornerBit.RIGHT); + } else if ( + isAvailableLeft && isAnchoredToRight && isRtl || + (isAvailableLeft && !isAnchoredToRight && hasRightBit) || + (!isAvailableRight && availableLeft >= availableRight)) { + // Attach right side of surface to the anchor. + corner = this.setBit(corner, CornerBit.RIGHT); + } + + return corner; + } + + /** + * @param corner Origin corner of the menu surface. + * @return Maximum height of the menu surface, based on available space. 0 + * indicates should not be set. + */ + private getMenuSurfaceMaxHeight(corner: Corner): number { + if (this.maxHeight > 0) { + return this.maxHeight; + } + + const {viewportDistance} = this.measurements; + + let maxHeight = 0; + const isBottomAligned = this.hasBit(corner, CornerBit.BOTTOM); + const isBottomAnchored = this.hasBit(this.anchorCorner, CornerBit.BOTTOM); + const {MARGIN_TO_EDGE} = MDCMenuSurfaceFoundation.numbers; + + // When maximum height is not specified, it is handled from CSS. + if (isBottomAligned) { + maxHeight = viewportDistance.top + this.anchorMargin.top - MARGIN_TO_EDGE; + if (!isBottomAnchored) { + maxHeight += this.measurements.anchorSize.height; + } + } else { + maxHeight = viewportDistance.bottom - this.anchorMargin.bottom + + this.measurements.anchorSize.height - MARGIN_TO_EDGE; + if (isBottomAnchored) { + maxHeight -= this.measurements.anchorSize.height; + } + } + + return maxHeight; + } + + /** + * @param corner Origin corner of the menu surface. + * @return Horizontal offset of menu surface origin corner from corresponding + * anchor corner. + */ + private getHorizontalOriginOffset(corner: Corner): number { + const {anchorSize} = this.measurements; + + // isRightAligned corresponds to using the 'right' property on the surface. + const isRightAligned = this.hasBit(corner, CornerBit.RIGHT); + const avoidHorizontalOverlap = + this.hasBit(this.anchorCorner, CornerBit.RIGHT); + + if (isRightAligned) { + const rightOffset = avoidHorizontalOverlap ? + anchorSize.width - this.anchorMargin.left : + this.anchorMargin.right; + + // For hoisted or fixed elements, adjust the offset by the difference + // between viewport width and body width so when we calculate the right + // value (`adjustPositionForHoistedElement`) based on the element + // position, the right property is correct. + if (this.isHoistedElement || this.isFixedPosition) { + return rightOffset - + (this.measurements.viewportSize.width - + this.measurements.bodySize.width); + } + + return rightOffset; + } + + return avoidHorizontalOverlap ? anchorSize.width - this.anchorMargin.right : + this.anchorMargin.left; + } + + /** + * @param corner Origin corner of the menu surface. + * @return Vertical offset of menu surface origin corner from corresponding + * anchor corner. + */ + private getVerticalOriginOffset(corner: Corner): number { + const {anchorSize} = this.measurements; + const isBottomAligned = this.hasBit(corner, CornerBit.BOTTOM); + const avoidVerticalOverlap = + this.hasBit(this.anchorCorner, CornerBit.BOTTOM); + + let y = 0; + if (isBottomAligned) { + y = avoidVerticalOverlap ? anchorSize.height - this.anchorMargin.top : + -this.anchorMargin.bottom; + } else { + y = avoidVerticalOverlap ? + (anchorSize.height + this.anchorMargin.bottom) : + this.anchorMargin.top; + } + return y; + } + + /** + * Calculates the offsets for positioning the menu-surface when the + * menu-surface has been hoisted to the body. + */ + private adjustPositionForHoistedElement(position: Partial) { + const {windowScroll, viewportDistance, surfaceSize, viewportSize} = + this.measurements; + + const props = + Object.keys(position) as Array>; + + for (const prop of props) { + let value = position[prop] || 0; + + if (this.isHorizontallyCenteredOnViewport && + (prop === 'left' || prop === 'right')) { + position[prop] = (viewportSize.width - surfaceSize.width) / 2; + continue; + } + + // Hoisted surfaces need to have the anchor elements location on the page + // added to the position properties for proper alignment on the body. + value += viewportDistance[prop]; + + // Surfaces that are absolutely positioned need to have additional + // calculations for scroll and bottom positioning. + if (!this.isFixedPosition) { + if (prop === 'top') { + value += windowScroll.y; + } else if (prop === 'bottom') { + value -= windowScroll.y; + } else if (prop === 'left') { + value += windowScroll.x; + } else { // prop === 'right' + value -= windowScroll.x; + } + } + + position[prop] = value; + } + } + + /** + * The last focused element when the menu surface was opened should regain + * focus, if the user is focused on or within the menu surface when it is + * closed. + */ + private maybeRestoreFocus() { + const isRootFocused = this.adapter.isFocused(); + const ownerDocument = this.adapter.getOwnerDocument ? + this.adapter.getOwnerDocument() : + document; + const childHasFocus = ownerDocument.activeElement && + this.adapter.isElementInContainer(ownerDocument.activeElement); + if (isRootFocused || childHasFocus) { + // Wait before restoring focus when closing the menu surface. This is + // important because if a touch event triggered the menu close, and the + // subsequent mouse event occurs after focus is restored, then the + // restored focus would be lost. + setTimeout(() => { + this.adapter.restoreFocus(); + }, numbers.TOUCH_EVENT_WAIT_MS); + } + } + + private hasBit(corner: Corner, bit: CornerBit): boolean { + return Boolean(corner & bit); // tslint:disable-line:no-bitwise + } + + private setBit(corner: Corner, bit: CornerBit): Corner { + return corner | bit; // tslint:disable-line:no-bitwise + } + + private unsetBit(corner: Corner, bit: CornerBit): Corner { + return corner ^ bit; + } + + /** + * isFinite that doesn't force conversion to number type. + * Equivalent to Number.isFinite in ES2015, which is not supported in IE. + */ + private isFinite(num: number): boolean { + return typeof num === 'number' && isFinite(num); + } +} + +// tslint:disable-next-line:no-default-export Needed for backward compatibility with MDC Web v0.44.0 and earlier. +export default MDCMenuSurfaceFoundation; diff --git a/menusurface/lib/menu-surface-styles.scss b/menusurface/lib/menu-surface-styles.scss new file mode 100644 index 0000000000..761b5bcd49 --- /dev/null +++ b/menusurface/lib/menu-surface-styles.scss @@ -0,0 +1,8 @@ +// +// Copyright 2022 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// + +@use './mixins'; + +@include mixins.core-styles(); diff --git a/menusurface/lib/menu-surface.ts b/menusurface/lib/menu-surface.ts new file mode 100644 index 0000000000..8f7e265118 --- /dev/null +++ b/menusurface/lib/menu-surface.ts @@ -0,0 +1,376 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Style preference for leading underscores. +// tslint:disable:strip-private-property-underscore + +// TODO(b/): remove compat dependencies +import {observer} from '@material/web/compat/base/observer'; +import {deepActiveElementPath, doesElementContainFocus} from '@material/web/compat/base/utils'; +import {html, LitElement} from 'lit'; +import {property, query, state} from 'lit/decorators'; +import {classMap} from 'lit/directives/class-map'; +import {styleMap} from 'lit/directives/style-map'; + +import {MDCMenuSurfaceAdapter} from './adapter'; +import {Corner as CornerEnum, CornerBit} from './constants'; +import MDCMenuSurfaceFoundation from './foundation'; + +export type Corner = keyof typeof CornerEnum; +export type AnchorableElement = HTMLElement&{anchor: Element | null}; +export type MenuCorner = 'START'|'END'; + +// tslint:disable:no-bitwise + +// required for closure compiler +const stringToCorner = { + 'TOP_LEFT': CornerEnum.TOP_LEFT, + 'TOP_RIGHT': CornerEnum.TOP_RIGHT, + 'BOTTOM_LEFT': CornerEnum.BOTTOM_LEFT, + 'BOTTOM_RIGHT': CornerEnum.BOTTOM_RIGHT, + 'TOP_START': CornerEnum.TOP_START, + 'TOP_END': CornerEnum.TOP_END, + 'BOTTOM_START': CornerEnum.BOTTOM_START, + 'BOTTOM_END': CornerEnum.BOTTOM_END, +}; + +/** + * @fires opened + * @fires closed + */ +export abstract class MenuSurface extends LitElement { + protected mdcFoundation!: MDCMenuSurfaceFoundation; + + @query('.md3-menu-surface') mdcRoot!: HTMLDivElement; + + @query('slot') slotElement!: HTMLSlotElement|null; + + @property({type: Boolean}) + @observer(function(this: MenuSurface, isAbsolute: boolean) { + if (this.mdcFoundation && !this.fixed) { + this.mdcFoundation.setIsHoisted(isAbsolute); + } + }) + absolute = false; + + @property({type: Boolean}) fullwidth = false; + + @property({type: Boolean}) + @observer(function(this: MenuSurface, isFixed: boolean) { + if (this.mdcFoundation && !this.absolute) { + this.mdcFoundation.setFixedPosition(isFixed); + } + }) + fixed = false; + + @property({type: Number}) + @observer(function(this: MenuSurface, value: number|null) { + if (this.mdcFoundation && this.y !== null && value !== null) { + this.mdcFoundation.setAbsolutePosition(value, this.y); + this.mdcFoundation.setAnchorMargin( + {left: value, top: this.y, right: -value, bottom: this.y}); + } + }) + x: number|null = null; + + @property({type: Number}) + @observer(function(this: MenuSurface, value: number|null) { + if (this.mdcFoundation && this.x !== null && value !== null) { + this.mdcFoundation.setAbsolutePosition(this.x, value); + this.mdcFoundation.setAnchorMargin( + {left: this.x, top: value, right: -this.x, bottom: value}); + } + }) + y: number|null = null; + + // must be defined before open or else race condition in foundation occurs. + @property({type: Boolean}) + @observer(function(this: MenuSurface, value: boolean) { + if (this.mdcFoundation) { + this.mdcFoundation.setQuickOpen(value); + } + }) + quick = false; + + @property({type: Boolean, reflect: true}) + @observer(function(this: MenuSurface, isOpen: boolean, wasOpen: boolean) { + if (this.mdcFoundation) { + if (isOpen) { + this.mdcFoundation.open(); + // wasOpen helps with first render (when it is `undefined`) perf + } else if (wasOpen !== undefined) { + this.mdcFoundation.close(); + } + } + }) + open = false; + + @property({type: Boolean}) stayOpenOnBodyClick: boolean = false; + + @state() + @observer(function(this: MenuSurface, value: CornerEnum) { + if (this.mdcFoundation) { + if (value) { + this.mdcFoundation.setAnchorCorner(value); + } else { + this.mdcFoundation.setAnchorCorner(value); + } + } + }) + + protected bitwiseCorner: CornerEnum = CornerEnum.TOP_START; + protected previousMenuCorner: MenuCorner|null = null; + + // must be defined before observer of anchor corner for initialization + @property({type: String}) + @observer(function(this: MenuSurface, value: MenuCorner) { + if (this.mdcFoundation) { + const isValidValue = value === 'START' || value === 'END'; + const isFirstTimeSet = this.previousMenuCorner === null; + const cornerChanged = + !isFirstTimeSet && value !== this.previousMenuCorner; + const initiallySetToEnd = isFirstTimeSet && value === 'END'; + + if (isValidValue && (cornerChanged || initiallySetToEnd)) { + this.bitwiseCorner = this.bitwiseCorner ^ CornerBit.RIGHT; + this.mdcFoundation.flipCornerHorizontally(); + this.previousMenuCorner = value; + } + } + }) + menuCorner: MenuCorner = 'START'; + + @property({type: String}) + @observer(function(this: MenuSurface, value: Corner) { + if (this.mdcFoundation) { + if (value) { + let newCorner = stringToCorner[value]; + if (this.menuCorner === 'END') { + newCorner = newCorner ^ CornerBit.RIGHT; + } + + this.bitwiseCorner = newCorner; + } + } + }) + corner: Corner = 'TOP_START'; + + @state() protected styleTop = ''; + @state() protected styleLeft = ''; + @state() protected styleRight = ''; + @state() protected styleBottom = ''; + @state() protected styleMaxHeight = ''; + @state() protected styleTransformOrigin = ''; + + anchor: HTMLElement|null = null; + + protected previouslyFocused: HTMLElement|Element|null = null; + protected previousAnchor: HTMLElement|null = null; + protected onBodyClickBound: (evt: MouseEvent) => void = () => undefined; + + override render() { + const classes = { + 'md3-menu-surface--fixed': this.fixed, + 'md3-menu-surface--fullwidth': this.fullwidth, + }; + + const styles = { + 'top': this.styleTop, + 'left': this.styleLeft, + 'right': this.styleRight, + 'bottom': this.styleBottom, + 'max-height': this.styleMaxHeight, + 'transform-origin': this.styleTransformOrigin, + }; + + return html` +
+ +
`; + } + + protected override firstUpdated() { + if (this.mdcFoundation !== undefined) { + this.mdcFoundation.destroy(); + } + + this.mdcFoundation = new MDCMenuSurfaceFoundation(this.createAdapter()); + this.mdcFoundation.init(); + } + + createAdapter(): MDCMenuSurfaceAdapter { + return { + addClass: (className: string) => { + this.mdcRoot.classList.add(className); + }, + removeClass: (className: string) => { + this.mdcRoot.classList.remove(className); + }, + hasClass: (className: string) => + this.mdcRoot.classList.contains(className), + hasAnchor: () => { + return !!this.anchor; + }, + notifyClose: () => { + const init: CustomEventInit = {bubbles: true, composed: true}; + const ev = new CustomEvent('closed', init); + this.open = false; + this.mdcRoot.dispatchEvent(ev); + }, + notifyClosing: () => { + const init: CustomEventInit = {bubbles: true, composed: true}; + const ev = new CustomEvent('closing', init); + this.mdcRoot.dispatchEvent(ev); + }, + notifyOpen: () => { + const init: CustomEventInit = {bubbles: true, composed: true}; + const ev = new CustomEvent('opened', init); + this.open = true; + this.mdcRoot.dispatchEvent(ev); + }, + notifyOpening: () => { + const init: CustomEventInit = {bubbles: true, composed: true}; + const ev = new CustomEvent('opening', init); + this.mdcRoot.dispatchEvent(ev); + }, + isElementInContainer: () => false, + isRtl: () => { + if (this.mdcRoot) { + return getComputedStyle(this.mdcRoot).direction === 'rtl'; + } + + return false; + }, + setTransformOrigin: (origin) => { + const root = this.mdcRoot; + if (!root) { + return; + } + + this.styleTransformOrigin = origin; + }, + isFocused: () => { + return doesElementContainFocus(this); + }, + saveFocus: () => { + const activeElementPath = deepActiveElementPath(); + const pathLength = activeElementPath.length; + + if (!pathLength) { + this.previouslyFocused = null; + } + + this.previouslyFocused = activeElementPath[pathLength - 1]; + }, + restoreFocus: () => { + if (!this.previouslyFocused) { + return; + } + + if ('focus' in this.previouslyFocused) { + this.previouslyFocused.focus(); + } + }, + getInnerDimensions: () => { + const mdcRoot = this.mdcRoot; + + if (!mdcRoot) { + return {width: 0, height: 0}; + } + + return {width: mdcRoot.offsetWidth, height: mdcRoot.offsetHeight}; + }, + getAnchorDimensions: () => { + const anchorElement = this.anchor; + + return anchorElement ? anchorElement.getBoundingClientRect() : null; + }, + getBodyDimensions: () => { + return { + width: document.body.clientWidth, + height: document.body.clientHeight, + }; + }, + getWindowDimensions: () => { + return { + width: window.innerWidth, + height: window.innerHeight, + }; + }, + getWindowScroll: () => { + return { + x: window.pageXOffset, + y: window.pageYOffset, + }; + }, + setPosition: (position) => { + const mdcRoot = this.mdcRoot; + + if (!mdcRoot) { + return; + } + + this.styleLeft = 'left' in position ? `${position.left}px` : ''; + this.styleRight = 'right' in position ? `${position.right}px` : ''; + this.styleTop = 'top' in position ? `${position.top}px` : ''; + this.styleBottom = 'bottom' in position ? `${position.bottom}px` : ''; + }, + setMaxHeight: async (height) => { + const mdcRoot = this.mdcRoot; + + if (!mdcRoot) { + return; + } + + // must set both for IE support as IE will not set a var + this.styleMaxHeight = height; + await this.updateComplete; + this.styleMaxHeight = `var(--md3-menu-max-height, ${height})`; + }, + }; + } + + protected onKeydown(evt: KeyboardEvent) { + if (this.mdcFoundation) { + this.mdcFoundation.handleKeydown(evt); + } + } + + protected onBodyClick(evt: MouseEvent) { + if (this.stayOpenOnBodyClick) { + return; + } + const path = evt.composedPath(); + if (path.indexOf(this) === -1) { + this.close(); + } + } + + protected registerBodyClick() { + this.onBodyClickBound = this.onBodyClick.bind(this); + // capture otherwise listener closes menu after quick menu opens + document.body.addEventListener( + 'click', this.onBodyClickBound, {passive: true, capture: true}); + } + + protected deregisterBodyClick() { + document.body.removeEventListener( + 'click', this.onBodyClickBound, {capture: true}); + } + + close() { + this.open = false; + } + + show() { + this.open = true; + } +} diff --git a/menusurface/lib/types.ts b/menusurface/lib/types.ts new file mode 100644 index 0000000000..4d9579795e --- /dev/null +++ b/menusurface/lib/types.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface MDCMenuDimensions { + width: number; + height: number; +} + +export interface MDCMenuDistance { + top: number; + right: number; + bottom: number; + left: number; +} + +export interface MDCMenuPoint { + x: number; + y: number; +} diff --git a/menusurface/menu-surface.ts b/menusurface/menu-surface.ts new file mode 100644 index 0000000000..7212c26a6b --- /dev/null +++ b/menusurface/menu-surface.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {customElement} from 'lit/decorators'; + +import {MenuSurface} from './lib/menu-surface'; +import {styles} from './lib/menu-surface-styles.css'; + +declare global { + interface HTMLElementTagNameMap { + 'md-menu-surface': MdMenuSurface; + } +} + +@customElement('md-menu-surface') +export class MdMenuSurface extends MenuSurface { + static override styles = [styles]; +}