@ionic/core
Version:
Base components for Ionic
746 lines (745 loc) • 27.3 kB
JavaScript
/*!
* (C) Ionic http://ionicframework.com - MIT License
*/
import { getElementRoot, raf } from "../../utils/helpers";
/**
* Returns the dimensions of the popover
* arrow on `ios` mode. If arrow is disabled
* returns (0, 0).
*/
export const getArrowDimensions = (arrowEl) => {
if (!arrowEl) {
return { arrowWidth: 0, arrowHeight: 0 };
}
const { width, height } = arrowEl.getBoundingClientRect();
return { arrowWidth: width, arrowHeight: height };
};
/**
* Returns the recommended dimensions of the popover
* that takes into account whether or not the width
* should match the trigger width.
*/
export const getPopoverDimensions = (size, contentEl, triggerEl) => {
const contentDimentions = contentEl.getBoundingClientRect();
const contentHeight = contentDimentions.height;
let contentWidth = contentDimentions.width;
if (size === 'cover' && triggerEl) {
const triggerDimensions = triggerEl.getBoundingClientRect();
contentWidth = triggerDimensions.width;
}
return {
contentWidth,
contentHeight,
};
};
export const configureDismissInteraction = (triggerEl, triggerAction, popoverEl, parentPopoverEl) => {
let dismissCallbacks = [];
const root = getElementRoot(parentPopoverEl);
const parentContentEl = root.querySelector('.popover-content');
switch (triggerAction) {
case 'hover':
dismissCallbacks = [
{
/**
* Do not use mouseover here
* as this will causes the event to
* be dispatched on each underlying
* element rather than on the popover
* content as a whole.
*/
eventName: 'mouseenter',
callback: (ev) => {
/**
* Do not dismiss the popover is we
* are hovering over its trigger.
* This would be easier if we used mouseover
* but this would cause the event to be dispatched
* more often than we would like, potentially
* causing performance issues.
*/
const element = document.elementFromPoint(ev.clientX, ev.clientY);
if (element === triggerEl) {
return;
}
popoverEl.dismiss(undefined, undefined, false);
},
},
];
break;
case 'context-menu':
case 'click':
default:
dismissCallbacks = [
{
eventName: 'click',
callback: (ev) => {
/**
* Do not dismiss the popover is we
* are hovering over its trigger.
*/
const target = ev.target;
const closestTrigger = target.closest('[data-ion-popover-trigger]');
if (closestTrigger === triggerEl) {
/**
* stopPropagation here so if the
* popover has dismissOnSelect="true"
* the popover does not dismiss since
* we just clicked a trigger element.
*/
ev.stopPropagation();
return;
}
popoverEl.dismiss(undefined, undefined, false);
},
},
];
break;
}
dismissCallbacks.forEach(({ eventName, callback }) => parentContentEl.addEventListener(eventName, callback));
return () => {
dismissCallbacks.forEach(({ eventName, callback }) => parentContentEl.removeEventListener(eventName, callback));
};
};
/**
* Configures the triggerEl to respond
* to user interaction based upon the triggerAction
* prop that devs have defined.
*/
export const configureTriggerInteraction = (triggerEl, triggerAction, popoverEl) => {
let triggerCallbacks = [];
/**
* Based upon the kind of trigger interaction
* the user wants, we setup the correct event
* listeners.
*/
switch (triggerAction) {
case 'hover':
let hoverTimeout;
triggerCallbacks = [
{
eventName: 'mouseenter',
callback: async (ev) => {
ev.stopPropagation();
if (hoverTimeout) {
clearTimeout(hoverTimeout);
}
/**
* Hovering over a trigger should not
* immediately open the next popover.
*/
hoverTimeout = setTimeout(() => {
raf(() => {
popoverEl.presentFromTrigger(ev);
hoverTimeout = undefined;
});
}, 100);
},
},
{
eventName: 'mouseleave',
callback: (ev) => {
if (hoverTimeout) {
clearTimeout(hoverTimeout);
}
/**
* If mouse is over another popover
* that is not this popover then we should
* close this popover.
*/
const target = ev.relatedTarget;
if (!target) {
return;
}
if (target.closest('ion-popover') !== popoverEl) {
popoverEl.dismiss(undefined, undefined, false);
}
},
},
{
/**
* stopPropagation here prevents the popover
* from dismissing when dismiss-on-select="true".
*/
eventName: 'click',
callback: (ev) => ev.stopPropagation(),
},
{
eventName: 'ionPopoverActivateTrigger',
callback: (ev) => popoverEl.presentFromTrigger(ev, true),
},
];
break;
case 'context-menu':
triggerCallbacks = [
{
eventName: 'contextmenu',
callback: (ev) => {
/**
* Prevents the platform context
* menu from appearing.
*/
ev.preventDefault();
popoverEl.presentFromTrigger(ev);
},
},
{
eventName: 'click',
callback: (ev) => ev.stopPropagation(),
},
{
eventName: 'ionPopoverActivateTrigger',
callback: (ev) => popoverEl.presentFromTrigger(ev, true),
},
];
break;
case 'click':
default:
triggerCallbacks = [
{
/**
* Do not do a stopPropagation() here
* because if you had two click triggers
* then clicking the first trigger and then
* clicking the second trigger would not cause
* the first popover to dismiss.
*/
eventName: 'click',
callback: (ev) => popoverEl.presentFromTrigger(ev),
},
{
eventName: 'ionPopoverActivateTrigger',
callback: (ev) => popoverEl.presentFromTrigger(ev, true),
},
];
break;
}
triggerCallbacks.forEach(({ eventName, callback }) => triggerEl.addEventListener(eventName, callback));
triggerEl.setAttribute('data-ion-popover-trigger', 'true');
return () => {
triggerCallbacks.forEach(({ eventName, callback }) => triggerEl.removeEventListener(eventName, callback));
triggerEl.removeAttribute('data-ion-popover-trigger');
};
};
/**
* Returns the index of an ion-item in an array of ion-items.
*/
export const getIndexOfItem = (items, item) => {
if (!item || item.tagName !== 'ION-ITEM') {
return -1;
}
return items.findIndex((el) => el === item);
};
/**
* Given an array of elements and a currently focused ion-item
* returns the next ion-item relative to the focused one or
* undefined.
*/
export const getNextItem = (items, currentItem) => {
const currentItemIndex = getIndexOfItem(items, currentItem);
return items[currentItemIndex + 1];
};
/**
* Given an array of elements and a currently focused ion-item
* returns the previous ion-item relative to the focused one or
* undefined.
*/
export const getPrevItem = (items, currentItem) => {
const currentItemIndex = getIndexOfItem(items, currentItem);
return items[currentItemIndex - 1];
};
/** Focus the internal button of the ion-item */
const focusItem = (item) => {
const root = getElementRoot(item);
const button = root.querySelector('button');
if (button) {
raf(() => button.focus());
}
};
/**
* Returns `true` if `el` has been designated
* as a trigger element for an ion-popover.
*/
export const isTriggerElement = (el) => el.hasAttribute('data-ion-popover-trigger');
export const configureKeyboardInteraction = (popoverEl) => {
const callback = async (ev) => {
var _a;
const activeElement = document.activeElement;
let items = [];
const targetTagName = (_a = ev.target) === null || _a === void 0 ? void 0 : _a.tagName;
/**
* Only handle custom keyboard interactions for the host popover element
* and children ion-item elements.
*/
if (targetTagName !== 'ION-POPOVER' && targetTagName !== 'ION-ITEM') {
return;
}
/**
* Complex selectors with :not() are :not supported
* in older versions of Chromium so we need to do a
* try/catch here so errors are not thrown.
*/
try {
/**
* Select all ion-items that are not children of child popovers.
* i.e. only select ion-item elements that are part of this popover
*/
items = Array.from(popoverEl.querySelectorAll('ion-item:not(ion-popover ion-popover *):not([disabled])'));
/* eslint-disable-next-line */
}
catch (_b) { }
switch (ev.key) {
/**
* If we are in a child popover
* then pressing the left arrow key
* should close this popover and move
* focus to the popover that presented
* this one.
*/
case 'ArrowLeft':
const parentPopover = await popoverEl.getParentPopover();
if (parentPopover) {
popoverEl.dismiss(undefined, undefined, false);
}
break;
/**
* ArrowDown should move focus to the next focusable ion-item.
*/
case 'ArrowDown':
// Disable movement/scroll with keyboard
ev.preventDefault();
const nextItem = getNextItem(items, activeElement);
if (nextItem !== undefined) {
focusItem(nextItem);
}
break;
/**
* ArrowUp should move focus to the previous focusable ion-item.
*/
case 'ArrowUp':
// Disable movement/scroll with keyboard
ev.preventDefault();
const prevItem = getPrevItem(items, activeElement);
if (prevItem !== undefined) {
focusItem(prevItem);
}
break;
/**
* Home should move focus to the first focusable ion-item.
*/
case 'Home':
ev.preventDefault();
const firstItem = items[0];
if (firstItem !== undefined) {
focusItem(firstItem);
}
break;
/**
* End should move focus to the last focusable ion-item.
*/
case 'End':
ev.preventDefault();
const lastItem = items[items.length - 1];
if (lastItem !== undefined) {
focusItem(lastItem);
}
break;
/**
* ArrowRight, Spacebar, or Enter should activate
* the currently focused trigger item to open a
* popover if the element is a trigger item.
*/
case 'ArrowRight':
case ' ':
case 'Enter':
if (activeElement && isTriggerElement(activeElement)) {
const rightEvent = new CustomEvent('ionPopoverActivateTrigger');
activeElement.dispatchEvent(rightEvent);
}
break;
default:
break;
}
};
popoverEl.addEventListener('keydown', callback);
return () => popoverEl.removeEventListener('keydown', callback);
};
/**
* Positions a popover by taking into account
* the reference point, preferred side, alignment
* and viewport dimensions.
*/
export const getPopoverPosition = (isRTL, contentWidth, contentHeight, arrowWidth, arrowHeight, reference, side, align, defaultPosition, triggerEl, event) => {
var _a;
let referenceCoordinates = {
top: 0,
left: 0,
width: 0,
height: 0,
};
/**
* Calculate position relative to the
* x-y coordinates in the event that
* was passed in
*/
switch (reference) {
case 'event':
if (!event) {
return defaultPosition;
}
const mouseEv = event;
referenceCoordinates = {
top: mouseEv.clientY,
left: mouseEv.clientX,
width: 1,
height: 1,
};
break;
/**
* Calculate position relative to the bounding
* box on either the trigger element
* specified via the `trigger` prop or
* the target specified on the event
* that was passed in.
*/
case 'trigger':
default:
const customEv = event;
/**
* ionShadowTarget is used when we need to align the
* popover with an element inside of the shadow root
* of an Ionic component. Ex: Presenting a popover
* by clicking on the collapsed indicator inside
* of `ion-breadcrumb` and centering it relative
* to the indicator rather than `ion-breadcrumb`
* as a whole.
*/
const actualTriggerEl = (triggerEl ||
((_a = customEv === null || customEv === void 0 ? void 0 : customEv.detail) === null || _a === void 0 ? void 0 : _a.ionShadowTarget) ||
(customEv === null || customEv === void 0 ? void 0 : customEv.target));
if (!actualTriggerEl) {
return defaultPosition;
}
const triggerBoundingBox = actualTriggerEl.getBoundingClientRect();
referenceCoordinates = {
top: triggerBoundingBox.top,
left: triggerBoundingBox.left,
width: triggerBoundingBox.width,
height: triggerBoundingBox.height,
};
break;
}
/**
* Get top/left offset that would allow
* popover to be positioned on the
* preferred side of the reference.
*/
const coordinates = calculatePopoverSide(side, referenceCoordinates, contentWidth, contentHeight, arrowWidth, arrowHeight, isRTL);
/**
* Get the top/left adjustments that
* would allow the popover content
* to have the correct alignment.
*/
const alignedCoordinates = calculatePopoverAlign(align, side, referenceCoordinates, contentWidth, contentHeight);
const top = coordinates.top + alignedCoordinates.top;
const left = coordinates.left + alignedCoordinates.left;
const { arrowTop, arrowLeft } = calculateArrowPosition(side, arrowWidth, arrowHeight, top, left, contentWidth, contentHeight, isRTL);
const { originX, originY } = calculatePopoverOrigin(side, align, isRTL);
return { top, left, referenceCoordinates, arrowTop, arrowLeft, originX, originY };
};
/**
* Determines the transform-origin
* of the popover animation so that it
* is in line with what the side and alignment
* prop values are. Currently only used
* with the MD animation.
*/
const calculatePopoverOrigin = (side, align, isRTL) => {
switch (side) {
case 'top':
return { originX: getOriginXAlignment(align), originY: 'bottom' };
case 'bottom':
return { originX: getOriginXAlignment(align), originY: 'top' };
case 'left':
return { originX: 'right', originY: getOriginYAlignment(align) };
case 'right':
return { originX: 'left', originY: getOriginYAlignment(align) };
case 'start':
return { originX: isRTL ? 'left' : 'right', originY: getOriginYAlignment(align) };
case 'end':
return { originX: isRTL ? 'right' : 'left', originY: getOriginYAlignment(align) };
}
};
const getOriginXAlignment = (align) => {
switch (align) {
case 'start':
return 'left';
case 'center':
return 'center';
case 'end':
return 'right';
}
};
const getOriginYAlignment = (align) => {
switch (align) {
case 'start':
return 'top';
case 'center':
return 'center';
case 'end':
return 'bottom';
}
};
/**
* Calculates where the arrow positioning
* should be relative to the popover content.
*/
const calculateArrowPosition = (side, arrowWidth, arrowHeight, top, left, contentWidth, contentHeight, isRTL) => {
/**
* Note: When side is left, right, start, or end, the arrow is
* been rotated using a `transform`, so to move the arrow up or down
* by its dimension, you need to use `arrowWidth`.
*/
const leftPosition = {
arrowTop: top + contentHeight / 2 - arrowWidth / 2,
arrowLeft: left + contentWidth - arrowWidth / 2,
};
/**
* Move the arrow to the left by arrowWidth and then
* again by half of its width because we have rotated
* the arrow using a transform.
*/
const rightPosition = { arrowTop: top + contentHeight / 2 - arrowWidth / 2, arrowLeft: left - arrowWidth * 1.5 };
switch (side) {
case 'top':
return { arrowTop: top + contentHeight, arrowLeft: left + contentWidth / 2 - arrowWidth / 2 };
case 'bottom':
return { arrowTop: top - arrowHeight, arrowLeft: left + contentWidth / 2 - arrowWidth / 2 };
case 'left':
return leftPosition;
case 'right':
return rightPosition;
case 'start':
return isRTL ? rightPosition : leftPosition;
case 'end':
return isRTL ? leftPosition : rightPosition;
default:
return { arrowTop: 0, arrowLeft: 0 };
}
};
/**
* Calculates the required top/left
* values needed to position the popover
* content on the side specified in the
* `side` prop.
*/
const calculatePopoverSide = (side, triggerBoundingBox, contentWidth, contentHeight, arrowWidth, arrowHeight, isRTL) => {
const sideLeft = {
top: triggerBoundingBox.top,
left: triggerBoundingBox.left - contentWidth - arrowWidth,
};
const sideRight = {
top: triggerBoundingBox.top,
left: triggerBoundingBox.left + triggerBoundingBox.width + arrowWidth,
};
switch (side) {
case 'top':
return {
top: triggerBoundingBox.top - contentHeight - arrowHeight,
left: triggerBoundingBox.left,
};
case 'right':
return sideRight;
case 'bottom':
return {
top: triggerBoundingBox.top + triggerBoundingBox.height + arrowHeight,
left: triggerBoundingBox.left,
};
case 'left':
return sideLeft;
case 'start':
return isRTL ? sideRight : sideLeft;
case 'end':
return isRTL ? sideLeft : sideRight;
}
};
/**
* Calculates the required top/left
* offset values needed to provide the
* correct alignment regardless while taking
* into account the side the popover is on.
*/
const calculatePopoverAlign = (align, side, triggerBoundingBox, contentWidth, contentHeight) => {
switch (align) {
case 'center':
return calculatePopoverCenterAlign(side, triggerBoundingBox, contentWidth, contentHeight);
case 'end':
return calculatePopoverEndAlign(side, triggerBoundingBox, contentWidth, contentHeight);
case 'start':
default:
return { top: 0, left: 0 };
}
};
/**
* Calculate the end alignment for
* the popover. If side is on the x-axis
* then the align values refer to the top
* and bottom margins of the content.
* If side is on the y-axis then the
* align values refer to the left and right
* margins of the content.
*/
const calculatePopoverEndAlign = (side, triggerBoundingBox, contentWidth, contentHeight) => {
switch (side) {
case 'start':
case 'end':
case 'left':
case 'right':
return {
top: -(contentHeight - triggerBoundingBox.height),
left: 0,
};
case 'top':
case 'bottom':
default:
return {
top: 0,
left: -(contentWidth - triggerBoundingBox.width),
};
}
};
/**
* Calculate the center alignment for
* the popover. If side is on the x-axis
* then the align values refer to the top
* and bottom margins of the content.
* If side is on the y-axis then the
* align values refer to the left and right
* margins of the content.
*/
const calculatePopoverCenterAlign = (side, triggerBoundingBox, contentWidth, contentHeight) => {
switch (side) {
case 'start':
case 'end':
case 'left':
case 'right':
return {
top: -(contentHeight / 2 - triggerBoundingBox.height / 2),
left: 0,
};
case 'top':
case 'bottom':
default:
return {
top: 0,
left: -(contentWidth / 2 - triggerBoundingBox.width / 2),
};
}
};
/**
* Adjusts popover positioning coordinates
* such that popover does not appear offscreen
* or overlapping safe area bounds.
*/
export const calculateWindowAdjustment = (side, coordTop, coordLeft, bodyPadding, bodyWidth, bodyHeight, contentWidth, contentHeight, safeAreaMargin, contentOriginX, contentOriginY, triggerCoordinates, coordArrowTop = 0, coordArrowLeft = 0, arrowHeight = 0) => {
let arrowTop = coordArrowTop;
const arrowLeft = coordArrowLeft;
let left = coordLeft;
let top = coordTop;
let bottom;
let originX = contentOriginX;
let originY = contentOriginY;
let checkSafeAreaLeft = false;
let checkSafeAreaRight = false;
const triggerTop = triggerCoordinates
? triggerCoordinates.top + triggerCoordinates.height
: bodyHeight / 2 - contentHeight / 2;
const triggerHeight = triggerCoordinates ? triggerCoordinates.height : 0;
let addPopoverBottomClass = false;
/**
* Adjust popover so it does not
* go off the left of the screen.
*/
if (left < bodyPadding + safeAreaMargin) {
left = bodyPadding;
checkSafeAreaLeft = true;
originX = 'left';
/**
* Adjust popover so it does not
* go off the right of the screen.
*/
}
else if (contentWidth + bodyPadding + left + safeAreaMargin > bodyWidth) {
checkSafeAreaRight = true;
left = bodyWidth - contentWidth - bodyPadding;
originX = 'right';
}
/**
* Adjust popover so it does not
* go off the top of the screen.
* If popover is on the left or the right of
* the trigger, then we should not adjust top
* margins.
*/
if (triggerTop + triggerHeight + contentHeight > bodyHeight && (side === 'top' || side === 'bottom')) {
if (triggerTop - contentHeight > 0) {
/**
* While we strive to align the popover with the trigger
* on smaller screens this is not always possible. As a result,
* we adjust the popover up so that it does not hang
* off the bottom of the screen. However, we do not want to move
* the popover up so much that it goes off the top of the screen.
*
* We chose 12 here so that the popover position looks a bit nicer as
* it is not right up against the edge of the screen.
*/
top = Math.max(12, triggerTop - contentHeight - triggerHeight - (arrowHeight - 1));
arrowTop = top + contentHeight;
originY = 'bottom';
addPopoverBottomClass = true;
/**
* If not enough room for popover to appear
* above trigger, then cut it off.
*/
}
else {
bottom = bodyPadding;
}
}
return {
top,
left,
bottom,
originX,
originY,
checkSafeAreaLeft,
checkSafeAreaRight,
arrowTop,
arrowLeft,
addPopoverBottomClass,
};
};
export const shouldShowArrow = (side, didAdjustBounds = false, ev, trigger) => {
/**
* If no event provided and
* we do not have a trigger,
* then this popover was likely
* presented via the popoverController
* or users called `present` manually.
* In this case, the arrow should not be
* shown as we do not have a reference.
*/
if (!ev && !trigger) {
return false;
}
/**
* If popover is on the left or the right
* of a trigger, but we needed to adjust the
* popover due to screen bounds, then we should
* hide the arrow as it will never be pointing
* at the trigger.
*/
if (side !== 'top' && side !== 'bottom' && didAdjustBounds) {
return false;
}
return true;
};