@smui/menu-surface
Version:
Svelte Material UI - Menu Surface
595 lines • 24.4 kB
JavaScript
/**
* @license
* Copyright 2018 Google Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
import { MDCFoundation } from '@smui/base/foundation';
import { Corner, CornerBit, cssClasses, numbers, strings } from './constants';
/** MDC Menu Surface Foundation */
export class MDCMenuSurfaceFoundation extends MDCFoundation {
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() {
// 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,
getViewportDimensions: () => ({ 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,
registerWindowEventHandler: () => undefined,
deregisterWindowEventHandler: () => undefined,
};
// tslint:enable:object-literal-sort-keys
}
constructor(adapter) {
super(Object.assign(Object.assign({}, MDCMenuSurfaceFoundation.defaultAdapter), adapter));
this.isSurfaceOpen = false;
this.isQuickOpen = false;
this.isHoistedElement = false;
this.isFixedPosition = false;
this.isHorizontallyCenteredOnViewport = false;
this.maxHeight = 0;
this.openBottomBias = 0;
this.openAnimationEndTimerId = 0;
this.closeAnimationEndTimerId = 0;
this.animationRequestId = 0;
this.anchorCorner = Corner.TOP_START;
/**
* Corner of the menu surface to which menu surface is attached to anchor.
*
* Anchor corner --->+----------+
* | ANCHOR |
* +----------+
* Origin corner --->+--------------+
* | |
* | |
* | MENU SURFACE |
* | |
* | |
* +--------------+
*/
this.originCorner = Corner.TOP_START;
this.anchorMargin = {
top: 0,
right: 0,
bottom: 0,
left: 0,
};
this.position = { x: 0, y: 0 };
}
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;
}
this.resizeListener = this.handleResize.bind(this);
this.adapter.registerWindowEventHandler('resize', this.resizeListener);
}
destroy() {
clearTimeout(this.openAnimationEndTimerId);
clearTimeout(this.closeAnimationEndTimerId);
// Cancel any currently running animations.
cancelAnimationFrame(this.animationRequestId);
this.adapter.deregisterWindowEventHandler('resize', this.resizeListener);
}
/**
* @param corner Default anchor corner alignment of top-left menu surface
* corner.
*/
setAnchorCorner(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) {
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) {
this.isHoistedElement = isHoisted;
}
/**
* Used to set the menu-surface calculations based on a fixed position menu.
*/
setFixedPosition(isFixedPosition) {
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, y) {
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) {
this.isHorizontallyCenteredOnViewport = isCentered;
}
setQuickOpen(quickOpen) {
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) {
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) {
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;
}
this.adapter.registerWindowEventHandler('resize', this.resizeListener);
}
/**
* Closes the menu surface.
*/
close(skipRestoreFocus = false) {
if (!this.isSurfaceOpen) {
return;
}
this.adapter.notifyClosing();
this.adapter.deregisterWindowEventHandler('resize', this.resizeListener);
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(event) {
const el = event.target;
if (this.adapter.isElementInContainer(el)) {
return;
}
this.close();
}
/** Handle keys that close the surface. */
handleKeydown(event) {
const { keyCode, key } = event;
const isEscape = key === 'Escape' || keyCode === 27;
if (isEscape) {
this.close();
}
}
/** Handles resize events on the window. */
handleResize() {
this.dimensions = this.adapter.getInnerDimensions();
this.autoposition();
}
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 = {
[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.
*/
getAutoLayoutmeasurements() {
let anchorRect = this.adapter.getAnchorDimensions();
const bodySize = this.adapter.getBodyDimensions();
const viewportSize = this.adapter.getViewportDimensions();
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,
};
// 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.
*/
getoriginCorner() {
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.left;
availableRight = viewportDistance.right - this.anchorMargin.left;
}
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.
*/
getMenuSurfaceMaxHeight(corner) {
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.
*/
getHorizontalOriginOffset(corner) {
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.
*/
getVerticalOriginOffset(corner) {
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.
*/
adjustPositionForHoistedElement(position) {
const { windowScroll, viewportDistance, surfaceSize, viewportSize } = this.measurements;
const props = Object.keys(position);
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.
*/
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);
}
}
hasBit(corner, bit) {
return Boolean(corner & bit); // tslint:disable-line:no-bitwise
}
setBit(corner, bit) {
return corner | bit; // tslint:disable-line:no-bitwise
}
unsetBit(corner, bit) {
return corner ^ bit;
}
/**
* isFinite that doesn't force conversion to number type.
* Equivalent to Number.isFinite in ES2015, which is not supported in IE.
*/
isFinite(num) {
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;
//# sourceMappingURL=foundation.js.map