diff --git a/core/api.txt b/core/api.txt index de0aab9d54..76027636bb 100644 --- a/core/api.txt +++ b/core/api.txt @@ -824,19 +824,27 @@ ion-picker,css-prop,--min-width ion-picker,css-prop,--width ion-popover,shadow +ion-popover,prop,alignment,"center" | "end" | "start",'center',false,false ion-popover,prop,animated,boolean,true,false,false +ion-popover,prop,arrow,boolean,true,false,false ion-popover,prop,backdropDismiss,boolean,true,false,false ion-popover,prop,component,Function | HTMLElement | null | string | undefined,undefined,false,false ion-popover,prop,componentProps,undefined | { [key: string]: any; },undefined,false,false +ion-popover,prop,dismissOnSelect,boolean,false,false,false ion-popover,prop,enterAnimation,((baseEl: any, opts?: any) => Animation) | undefined,undefined,false,false ion-popover,prop,event,any,undefined,false,false ion-popover,prop,isOpen,boolean,false,false,false ion-popover,prop,keyboardClose,boolean,true,false,false ion-popover,prop,leaveAnimation,((baseEl: any, opts?: any) => Animation) | undefined,undefined,false,false ion-popover,prop,mode,"ios" | "md",undefined,false,false +ion-popover,prop,reference,"event" | "trigger",'trigger',false,false ion-popover,prop,showBackdrop,boolean,true,false,false +ion-popover,prop,side,"bottom" | "end" | "left" | "right" | "start" | "top",'bottom',false,false +ion-popover,prop,size,"auto" | "cover",'auto',false,false ion-popover,prop,translucent,boolean,false,false,false -ion-popover,method,dismiss,dismiss(data?: any, role?: string | undefined) => Promise +ion-popover,prop,trigger,string | undefined,undefined,false,false +ion-popover,prop,triggerAction,"click" | "context-menu" | "hover",'click',false,false +ion-popover,method,dismiss,dismiss(data?: any, role?: string | undefined, dismissParentPopover?: boolean) => Promise ion-popover,method,onDidDismiss,onDidDismiss() => Promise> ion-popover,method,onWillDismiss,onWillDismiss() => Promise> ion-popover,method,present,present() => Promise @@ -856,6 +864,8 @@ ion-popover,css-prop,--max-height ion-popover,css-prop,--max-width ion-popover,css-prop,--min-height ion-popover,css-prop,--min-width +ion-popover,css-prop,--offset-x +ion-popover,css-prop,--offset-y ion-popover,css-prop,--width ion-popover,part,arrow ion-popover,part,backdrop diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 5e3d35e304..c0c2e51281 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -5,7 +5,7 @@ * It contains typing information for all components that exist in this project. */ import { HTMLStencilElement, JSXBase } from "@stencil/core/internal"; -import { AccordionGroupChangeEventDetail, ActionSheetButton, AlertButton, AlertInput, AnimationBuilder, AutocompleteTypes, CheckboxChangeEventDetail, Color, ComponentProps, ComponentRef, DatetimeChangeEventDetail, DatetimeOptions, DomRenderFn, FooterHeightFn, FrameworkDelegate, HeaderFn, HeaderHeightFn, InputChangeEventDetail, ItemHeightFn, ItemRenderFn, ItemReorderEventDetail, MenuChangeEventDetail, NavComponent, NavComponentWithProps, NavOptions, OverlayEventDetail, PickerButton, PickerColumn, RadioGroupChangeEventDetail, RangeChangeEventDetail, RangeValue, RefresherEventDetail, RouteID, RouterDirection, RouterEventDetail, RouterOutletOptions, RouteWrite, ScrollBaseDetail, ScrollDetail, SearchbarChangeEventDetail, SegmentButtonLayout, SegmentChangeEventDetail, SelectChangeEventDetail, SelectInterface, SelectPopoverOption, Side, SpinnerTypes, StyleEventDetail, SwipeGestureHandler, TabBarChangedEventDetail, TabButtonClickEventDetail, TabButtonLayout, TextareaChangeEventDetail, TextFieldTypes, ToastButton, ToggleChangeEventDetail, TransitionDoneFn, TransitionInstruction, ViewController } from "./interface"; +import { AccordionGroupChangeEventDetail, ActionSheetButton, AlertButton, AlertInput, AnimationBuilder, AutocompleteTypes, CheckboxChangeEventDetail, Color, ComponentProps, ComponentRef, DatetimeChangeEventDetail, DatetimeOptions, DomRenderFn, FooterHeightFn, FrameworkDelegate, HeaderFn, HeaderHeightFn, InputChangeEventDetail, ItemHeightFn, ItemRenderFn, ItemReorderEventDetail, MenuChangeEventDetail, NavComponent, NavComponentWithProps, NavOptions, OverlayEventDetail, PickerButton, PickerColumn, PopoverSize, PositionAlign, PositionReference, PositionSide, RadioGroupChangeEventDetail, RangeChangeEventDetail, RangeValue, RefresherEventDetail, RouteID, RouterDirection, RouterEventDetail, RouterOutletOptions, RouteWrite, ScrollBaseDetail, ScrollDetail, SearchbarChangeEventDetail, SegmentButtonLayout, SegmentChangeEventDetail, SelectChangeEventDetail, SelectInterface, SelectPopoverOption, Side, SpinnerTypes, StyleEventDetail, SwipeGestureHandler, TabBarChangedEventDetail, TabButtonClickEventDetail, TabButtonLayout, TextareaChangeEventDetail, TextFieldTypes, ToastButton, ToggleChangeEventDetail, TransitionDoneFn, TransitionInstruction, TriggerAction, ViewController } from "./interface"; import { IonicSafeString } from "./utils/sanitization"; import { NavigationHookCallback } from "./components/route/route-interface"; import { SelectCompareFn } from "./components/select/select-interface"; @@ -1649,10 +1649,18 @@ export namespace Components { "col": PickerColumn; } interface IonPopover { + /** + * Describes how to align the popover content with the `reference` point. + */ + "alignment": PositionAlign; /** * If `true`, the popover will animate. */ "animated": boolean; + /** + * If `true`, the popover will display an arrow that points at the `reference` when running in `ios` mode on mobile. Does not apply in `md` mode or on desktop. + */ + "arrow": boolean; /** * If `true`, the popover will be dismissed when the backdrop is clicked. */ @@ -1674,8 +1682,13 @@ export namespace Components { * Dismiss the popover overlay after it has been presented. * @param data Any data to emit in the dismiss events. * @param role The role of the element that is dismissing the popover. For example, 'cancel' or 'backdrop'. + * @param dismissParentPopover If `true`, dismissing this popover will also dismiss a parent popover if this popover is nested. Defaults to `true`. */ - "dismiss": (data?: any, role?: string | undefined) => Promise; + "dismiss": (data?: any, role?: string | undefined, dismissParentPopover?: boolean) => Promise; + /** + * If `true`, the popover will be automatically dismissed when the content has been clicked. + */ + "dismissOnSelect": boolean; /** * Animation to use when the popover is presented. */ @@ -1684,6 +1697,7 @@ export namespace Components { * The event to pass to the popover animation. */ "event": any; + "getParentPopover": () => Promise; "inline": boolean; /** * If `true`, the popover will open. If `false`, the popover will close. Use this if you need finer grained control over presentation, otherwise just use the popoverController or the `trigger` property. Note: `isOpen` will not automatically be set back to `false` when the popover dismisses. You will need to do that in your code. @@ -1714,14 +1728,38 @@ export namespace Components { * Present the popover overlay after it has been created. */ "present": () => Promise; + /** + * When opening a popover from a trigger, we should not be modifying the `event` prop from inside the component. Additionally, when pressing the "Right" arrow key, we need to shift focus to the first descendant in the newly presented popover. + */ + "presentFromTrigger": (event?: any, focusDescendant?: boolean) => Promise; + /** + * Describes what to position the popover relative to. If `'trigger'`, the popover will be positioned relative to the trigger button. If passing in an event, this is determined via event.target. If `'event'`, the popover will be positioned relative to the x/y coordinates of the trigger action. If passing in an event, this is determined via event.clientX and event.clientY. + */ + "reference": PositionReference; /** * If `true`, a backdrop will be displayed behind the popover. */ "showBackdrop": boolean; + /** + * Describes which side of the `reference` point to position the popover on. The `'start'` and `'end'` values are RTL-aware, and the `'left'` and `'right'` values are not. + */ + "side": PositionSide; + /** + * Describes how to calculate the popover width. If `'cover'`, the popover width will match the width of the trigger. If `'auto'`, the popover width will be determined by the content in the popover. + */ + "size": PopoverSize; /** * If `true`, the popover will be translucent. Only applies when the mode is `"ios"` and the device supports [`backdrop-filter`](https://developer.mozilla.org/en-US/docs/Web/CSS/backdrop-filter#Browser_compatibility). */ "translucent": boolean; + /** + * An ID corresponding to the trigger element that causes the popover to open. Use the `trigger-action` property to customize the interaction that results in the popover opening. + */ + "trigger": string | undefined; + /** + * Describes what kind of interaction with the trigger that should cause the popover to open. Does not apply when the `trigger` property is `undefined`. If `'click'`, the popover will be presented when the trigger is left clicked. If `'hover'`, the popover will be presented when a pointer hovers over the trigger. If `'context-menu'`, the popover will be presented when the trigger is right clicked on desktop and long pressed on mobile. This will also prevent your device's normal context menu from appearing. + */ + "triggerAction": TriggerAction; } interface IonProgressBar { /** @@ -4986,10 +5024,18 @@ declare namespace LocalJSX { "onIonPickerColChange"?: (event: CustomEvent) => void; } interface IonPopover { + /** + * Describes how to align the popover content with the `reference` point. + */ + "alignment"?: PositionAlign; /** * If `true`, the popover will animate. */ "animated"?: boolean; + /** + * If `true`, the popover will display an arrow that points at the `reference` when running in `ios` mode on mobile. Does not apply in `md` mode or on desktop. + */ + "arrow"?: boolean; /** * If `true`, the popover will be dismissed when the backdrop is clicked. */ @@ -5007,6 +5053,10 @@ declare namespace LocalJSX { */ "cssClass"?: string | string[]; "delegate"?: FrameworkDelegate; + /** + * If `true`, the popover will be automatically dismissed when the content has been clicked. + */ + "dismissOnSelect"?: boolean; /** * Animation to use when the popover is presented. */ @@ -5065,14 +5115,34 @@ declare namespace LocalJSX { */ "onWillPresent"?: (event: CustomEvent) => void; "overlayIndex": number; + /** + * Describes what to position the popover relative to. If `'trigger'`, the popover will be positioned relative to the trigger button. If passing in an event, this is determined via event.target. If `'event'`, the popover will be positioned relative to the x/y coordinates of the trigger action. If passing in an event, this is determined via event.clientX and event.clientY. + */ + "reference"?: PositionReference; /** * If `true`, a backdrop will be displayed behind the popover. */ "showBackdrop"?: boolean; + /** + * Describes which side of the `reference` point to position the popover on. The `'start'` and `'end'` values are RTL-aware, and the `'left'` and `'right'` values are not. + */ + "side"?: PositionSide; + /** + * Describes how to calculate the popover width. If `'cover'`, the popover width will match the width of the trigger. If `'auto'`, the popover width will be determined by the content in the popover. + */ + "size"?: PopoverSize; /** * If `true`, the popover will be translucent. Only applies when the mode is `"ios"` and the device supports [`backdrop-filter`](https://developer.mozilla.org/en-US/docs/Web/CSS/backdrop-filter#Browser_compatibility). */ "translucent"?: boolean; + /** + * An ID corresponding to the trigger element that causes the popover to open. Use the `trigger-action` property to customize the interaction that results in the popover opening. + */ + "trigger"?: string | undefined; + /** + * Describes what kind of interaction with the trigger that should cause the popover to open. Does not apply when the `trigger` property is `undefined`. If `'click'`, the popover will be presented when the trigger is left clicked. If `'hover'`, the popover will be presented when a pointer hovers over the trigger. If `'context-menu'`, the popover will be presented when the trigger is right clicked on desktop and long pressed on mobile. This will also prevent your device's normal context menu from appearing. + */ + "triggerAction"?: TriggerAction; } interface IonProgressBar { /** diff --git a/core/src/components/popover/animations/ios.enter.ts b/core/src/components/popover/animations/ios.enter.ts index 17dc922659..f9cf995f62 100644 --- a/core/src/components/popover/animations/ios.enter.ts +++ b/core/src/components/popover/animations/ios.enter.ts @@ -1,104 +1,37 @@ import { Animation } from '../../../interface'; import { createAnimation } from '../../../utils/animation/animation'; import { getElementRoot } from '../../../utils/helpers'; +import { calculateWindowAdjustment, getArrowDimensions, getPopoverDimensions, getPopoverPosition, shouldShowArrow } from '../utils'; + +const POPOVER_IOS_BODY_PADDING = 5; /** * iOS Popover Enter Animation */ -export const iosEnterAnimation = (baseEl: HTMLElement, ev?: Event): Animation => { - let originY = 'top'; - let originX = 'left'; +export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation => { + const { event: ev, size, trigger, reference, side, align } = opts; + const doc = (baseEl.ownerDocument as any); + const isRTL = doc.dir === 'rtl'; + const bodyWidth = doc.defaultView.innerWidth; + const bodyHeight = doc.defaultView.innerHeight; const root = getElementRoot(baseEl); const contentEl = root.querySelector('.popover-content') as HTMLElement; - const contentDimentions = contentEl.getBoundingClientRect(); - const contentWidth = contentDimentions.width; - const contentHeight = contentDimentions.height; + const arrowEl = root.querySelector('.popover-arrow') as HTMLElement | null; - const bodyWidth = (baseEl.ownerDocument as any).defaultView.innerWidth; - const bodyHeight = (baseEl.ownerDocument as any).defaultView.innerHeight; + const { contentWidth, contentHeight } = getPopoverDimensions(size, contentEl, trigger); + const { arrowWidth, arrowHeight } = getArrowDimensions(arrowEl); - // If ev was passed, use that for target element - const targetDim = ev && ev.target && (ev.target as HTMLElement).getBoundingClientRect(); - - const targetTop = targetDim != null && 'top' in targetDim ? targetDim.top : bodyHeight / 2 - contentHeight / 2; - const targetLeft = targetDim != null && 'left' in targetDim ? targetDim.left : bodyWidth / 2; - const targetWidth = (targetDim && targetDim.width) || 0; - const targetHeight = (targetDim && targetDim.height) || 0; - - const arrowEl = root.querySelector('.popover-arrow') as HTMLElement; - - const arrowDim = arrowEl.getBoundingClientRect(); - const arrowWidth = arrowDim.width; - const arrowHeight = arrowDim.height; - - if (targetDim == null) { - arrowEl.style.display = 'none'; + const defaultPosition = { + top: bodyHeight / 2 - contentHeight / 2, + left: bodyWidth / 2 - contentWidth / 2, + originX: isRTL ? 'right' : 'left', + originY: 'top' } - const arrowCSS = { - top: targetTop + targetHeight, - left: targetLeft + targetWidth / 2 - arrowWidth / 2 - }; + const results = getPopoverPosition(isRTL, contentWidth, contentHeight, arrowWidth, arrowHeight, reference, side, align, defaultPosition, trigger, ev); - const popoverCSS: { top: any; left: any } = { - top: targetTop + targetHeight + (arrowHeight - 1), - left: targetLeft + targetWidth / 2 - contentWidth / 2 - }; - - // If the popover left is less than the padding it is off screen - // to the left so adjust it, else if the width of the popover - // exceeds the body width it is off screen to the right so adjust - // - let checkSafeAreaLeft = false; - let checkSafeAreaRight = false; - - // If the popover left is less than the padding it is off screen - // to the left so adjust it, else if the width of the popover - // exceeds the body width it is off screen to the right so adjust - // 25 is a random/arbitrary number. It seems to work fine for ios11 - // and iPhoneX. Is it perfect? No. Does it work? Yes. - if (popoverCSS.left < POPOVER_IOS_BODY_PADDING + 25) { - checkSafeAreaLeft = true; - popoverCSS.left = POPOVER_IOS_BODY_PADDING; - } else if ( - contentWidth + POPOVER_IOS_BODY_PADDING + popoverCSS.left + 25 > bodyWidth - ) { - // Ok, so we're on the right side of the screen, - // but now we need to make sure we're still a bit further right - // cus....notchurally... Again, 25 is random. It works tho - checkSafeAreaRight = true; - popoverCSS.left = bodyWidth - contentWidth - POPOVER_IOS_BODY_PADDING; - originX = 'right'; - } - - // make it pop up if there's room above - if (targetTop + targetHeight + contentHeight > bodyHeight && targetTop - contentHeight > 0) { - arrowCSS.top = targetTop - (arrowHeight + 1); - popoverCSS.top = targetTop - contentHeight - (arrowHeight - 1); - - baseEl.className = baseEl.className + ' popover-bottom'; - originY = 'bottom'; - // If there isn't room for it to pop up above the target cut it off - } else if (targetTop + targetHeight + contentHeight > bodyHeight) { - contentEl.style.bottom = POPOVER_IOS_BODY_PADDING + '%'; - } - - arrowEl.style.top = arrowCSS.top + 'px'; - arrowEl.style.left = arrowCSS.left + 'px'; - - contentEl.style.top = popoverCSS.top + 'px'; - contentEl.style.left = popoverCSS.left + 'px'; - - if (checkSafeAreaLeft) { - contentEl.style.left = `calc(${popoverCSS.left}px + var(--ion-safe-area-left, 0px))`; - } - - if (checkSafeAreaRight) { - contentEl.style.left = `calc(${popoverCSS.left}px - var(--ion-safe-area-right, 0px))`; - } - - contentEl.style.transformOrigin = originY + ' ' + originX; + const { originX, originY, top, left, bottom, checkSafeAreaLeft, checkSafeAreaRight, arrowTop, arrowLeft, addPopoverBottomClass } = calculateWindowAdjustment(side, results.top, results.left, POPOVER_IOS_BODY_PADDING, bodyWidth, bodyHeight, contentWidth, contentHeight, 25, results.originX, results.originY, results.referenceCoordinates, results.arrowTop, results.arrowLeft, arrowHeight); const baseAnimation = createAnimation(); const backdropAnimation = createAnimation(); @@ -119,7 +52,46 @@ export const iosEnterAnimation = (baseEl: HTMLElement, ev?: Event): Animation => return baseAnimation .easing('ease') .duration(100) + .beforeAddWrite(() => { + if (size === 'cover') { + baseEl.style.setProperty('--width', `${contentWidth}px`); + } + + if (addPopoverBottomClass) { + baseEl.classList.add('popover-bottom'); + } + + if (bottom !== undefined) { + contentEl.style.setProperty('bottom', `${bottom}px`); + } + + const safeAreaLeft = ' + var(--ion-safe-area-left, 0)'; + const safeAreaRight = ' - var(--ion-safe-area-right, 0)'; + + let leftValue = `${left}px`; + + if (checkSafeAreaLeft) { + leftValue = `${left}px${safeAreaLeft}`; + } + if (checkSafeAreaRight) { + leftValue = `${left}px${safeAreaRight}`; + } + + contentEl.style.setProperty('top', `calc(${top}px + var(--offset-y, 0))`); + contentEl.style.setProperty('left', `calc(${leftValue} + var(--offset-x, 0))`); + contentEl.style.setProperty('transform-origin', `${originY} ${originX}`); + + if (arrowEl !== null) { + const didAdjustBounds = results.top !== top || results.left !== left; + const showArrow = shouldShowArrow(side, didAdjustBounds, ev, trigger); + + if (showArrow) { + arrowEl.style.setProperty('top', `calc(${arrowTop}px + var(--offset-y, 0))`); + arrowEl.style.setProperty('left', `calc(${arrowLeft}px + var(--offset-x, 0))`); + } else { + arrowEl.style.setProperty('display', 'none'); + } + } + }) .addAnimation([backdropAnimation, wrapperAnimation]); }; - -const POPOVER_IOS_BODY_PADDING = 5; diff --git a/core/src/components/popover/animations/ios.leave.ts b/core/src/components/popover/animations/ios.leave.ts index 2d1553f315..3f79253343 100644 --- a/core/src/components/popover/animations/ios.leave.ts +++ b/core/src/components/popover/animations/ios.leave.ts @@ -7,6 +7,9 @@ import { getElementRoot } from '../../../utils/helpers'; */ export const iosLeaveAnimation = (baseEl: HTMLElement): Animation => { const root = getElementRoot(baseEl); + const contentEl = root.querySelector('.popover-content') as HTMLElement; + const arrowEl = root.querySelector('.popover-arrow') as HTMLElement | null; + const baseAnimation = createAnimation(); const backdropAnimation = createAnimation(); const wrapperAnimation = createAnimation(); @@ -21,6 +24,21 @@ export const iosLeaveAnimation = (baseEl: HTMLElement): Animation => { return baseAnimation .easing('ease') - .duration(500) + .afterAddWrite(() => { + baseEl.style.removeProperty('--width'); + baseEl.classList.remove('popover-bottom'); + + contentEl.style.removeProperty('top'); + contentEl.style.removeProperty('left'); + contentEl.style.removeProperty('bottom'); + contentEl.style.removeProperty('transform-origin'); + + if (arrowEl) { + arrowEl.style.removeProperty('top'); + arrowEl.style.removeProperty('left'); + arrowEl.style.removeProperty('display'); + } + }) + .duration(300) .addAnimation([backdropAnimation, wrapperAnimation]); }; diff --git a/core/src/components/popover/animations/md.enter.ts b/core/src/components/popover/animations/md.enter.ts index 63d8624b82..d925364ea2 100644 --- a/core/src/components/popover/animations/md.enter.ts +++ b/core/src/components/popover/animations/md.enter.ts @@ -1,84 +1,35 @@ import { Animation } from '../../../interface'; import { createAnimation } from '../../../utils/animation/animation'; import { getElementRoot } from '../../../utils/helpers'; +import { calculateWindowAdjustment, getPopoverDimensions, getPopoverPosition } from '../utils'; + +const POPOVER_MD_BODY_PADDING = 12; /** * Md Popover Enter Animation */ -export const mdEnterAnimation = (baseEl: HTMLElement, ev?: Event): Animation => { - const POPOVER_MD_BODY_PADDING = 12; +export const mdEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation => { + const { event: ev, size, trigger, reference, side, align } = opts; const doc = (baseEl.ownerDocument as any); const isRTL = doc.dir === 'rtl'; - let originY = 'top'; - let originX = isRTL ? 'right' : 'left'; - - const root = getElementRoot(baseEl); - const contentEl = root.querySelector('.popover-content') as HTMLElement; - const contentDimentions = contentEl.getBoundingClientRect(); - const contentWidth = contentDimentions.width; - const contentHeight = contentDimentions.height; - const bodyWidth = doc.defaultView.innerWidth; const bodyHeight = doc.defaultView.innerHeight; - // If ev was passed, use that for target element - const targetDim = - ev && ev.target && (ev.target as HTMLElement).getBoundingClientRect(); + const root = getElementRoot(baseEl); + const contentEl = root.querySelector('.popover-content') as HTMLElement; + const { contentWidth, contentHeight } = getPopoverDimensions(size, contentEl, trigger); - // As per MD spec, by default position the popover below the target (trigger) element - const targetTop = - targetDim != null && 'bottom' in targetDim - ? targetDim.bottom - : bodyHeight / 2 - contentHeight / 2; - - const targetLeft = - targetDim != null && 'left' in targetDim - ? isRTL - ? targetDim.left - contentWidth + targetDim.width - : targetDim.left - : bodyWidth / 2 - contentWidth / 2; - - const targetHeight = (targetDim && targetDim.height) || 0; - - const popoverCSS: { top: any; left: any } = { - top: targetTop, - left: targetLeft - }; - - // If the popover left is less than the padding it is off screen - // to the left so adjust it, else if the width of the popover - // exceeds the body width it is off screen to the right so adjust - if (popoverCSS.left < POPOVER_MD_BODY_PADDING) { - popoverCSS.left = POPOVER_MD_BODY_PADDING; - - // Same origin in this case for both LTR & RTL - // Note: in LTR, originX is already 'left' - originX = 'left'; - } else if ( - contentWidth + POPOVER_MD_BODY_PADDING + popoverCSS.left > - bodyWidth - ) { - popoverCSS.left = bodyWidth - contentWidth - POPOVER_MD_BODY_PADDING; - - // Same origin in this case for both LTR & RTL - // Note: in RTL, originX is already 'right' - originX = 'right'; + const defaultPosition = { + top: bodyHeight / 2 - contentHeight / 2, + left: bodyWidth / 2 - contentWidth / 2, + originX: isRTL ? 'right' : 'left', + originY: 'top' } - // If the popover when popped down stretches past bottom of screen, - // make it pop up if there's room above - if ( - targetTop + targetHeight + contentHeight > bodyHeight && - targetTop - contentHeight > 0 - ) { - popoverCSS.top = targetTop - contentHeight - targetHeight; - baseEl.className = baseEl.className + ' popover-bottom'; - originY = 'bottom'; - // If there isn't room for it to pop up above the target cut it off - } else if (targetTop + targetHeight + contentHeight > bodyHeight) { - contentEl.style.bottom = POPOVER_MD_BODY_PADDING + 'px'; - } + const results = getPopoverPosition(isRTL, contentWidth, contentHeight, 0, 0, reference, side, align, defaultPosition, trigger, ev); + + const { originX, originY, top, left, bottom } = calculateWindowAdjustment(side, results.top, results.left, POPOVER_MD_BODY_PADDING, bodyWidth, bodyHeight, contentWidth, contentHeight, 0, results.originX, results.originY, results.referenceCoordinates); const baseAnimation = createAnimation(); const backdropAnimation = createAnimation(); @@ -101,10 +52,15 @@ export const mdEnterAnimation = (baseEl: HTMLElement, ev?: Event): Animation => contentAnimation .addElement(contentEl) .beforeStyles({ - 'top': `${popoverCSS.top}px`, - 'left': `${popoverCSS.left}px`, + 'top': `calc(${top}px + var(--offset-y, 0px))`, + 'left': `calc(${left}px + var(--offset-x, 0px))`, 'transform-origin': `${originY} ${originX}` }) + .beforeAddWrite(() => { + if (bottom !== undefined) { + contentEl.style.setProperty('bottom', `${bottom}px`); + } + }) .fromTo('transform', 'scale(0.001)', 'scale(1)'); viewportAnimation @@ -114,5 +70,13 @@ export const mdEnterAnimation = (baseEl: HTMLElement, ev?: Event): Animation => return baseAnimation .easing('cubic-bezier(0.36,0.66,0.04,1)') .duration(300) + .beforeAddWrite(() => { + if (size === 'cover') { + baseEl.style.setProperty('--width', `${contentWidth}px`); + } + if (originY === 'bottom') { + baseEl.classList.add('popover-bottom'); + } + }) .addAnimation([backdropAnimation, wrapperAnimation, contentAnimation, viewportAnimation]); }; diff --git a/core/src/components/popover/animations/md.leave.ts b/core/src/components/popover/animations/md.leave.ts index 350940081e..27c542d639 100644 --- a/core/src/components/popover/animations/md.leave.ts +++ b/core/src/components/popover/animations/md.leave.ts @@ -7,6 +7,7 @@ import { getElementRoot } from '../../../utils/helpers'; */ export const mdLeaveAnimation = (baseEl: HTMLElement): Animation => { const root = getElementRoot(baseEl); + const contentEl = root.querySelector('.popover-content') as HTMLElement; const baseAnimation = createAnimation(); const backdropAnimation = createAnimation(); const wrapperAnimation = createAnimation(); @@ -21,6 +22,15 @@ export const mdLeaveAnimation = (baseEl: HTMLElement): Animation => { return baseAnimation .easing('ease') - .duration(500) + .afterAddWrite(() => { + baseEl.style.removeProperty('--width'); + baseEl.classList.remove('popover-bottom'); + + contentEl.style.removeProperty('top'); + contentEl.style.removeProperty('left'); + contentEl.style.removeProperty('bottom'); + contentEl.style.removeProperty('transform-origin'); + }) + .duration(150) .addAnimation([backdropAnimation, wrapperAnimation]); }; diff --git a/core/src/components/popover/popover-interface.ts b/core/src/components/popover/popover-interface.ts index 9ded4006dd..a5e31a4a56 100644 --- a/core/src/components/popover/popover-interface.ts +++ b/core/src/components/popover/popover-interface.ts @@ -17,4 +17,21 @@ export interface PopoverOptions { enterAnimation?: AnimationBuilder; leaveAnimation?: AnimationBuilder; + + size?: PopoverSize; + dismissOnSelect?: boolean; + reference?: PositionReference; + side?: PositionSide; + align?: PositionAlign; + + trigger?: string; + triggerAction?: string; } + +export type PopoverSize = 'cover' | 'auto'; + +export type TriggerAction = 'click' | 'hover' | 'context-menu'; + +export type PositionReference = 'trigger' | 'event'; +export type PositionSide = 'top' | 'right' | 'bottom' | 'left' | 'start' | 'end'; +export type PositionAlign = 'start' | 'center' | 'end'; diff --git a/core/src/components/popover/popover.ios.scss b/core/src/components/popover/popover.ios.scss index e89b72b25d..3b2de457aa 100644 --- a/core/src/components/popover/popover.ios.scss +++ b/core/src/components/popover/popover.ios.scss @@ -11,10 +11,18 @@ --backdrop-opacity: var(--ion-backdrop-opacity, 0.08); } +:host(.popover-desktop) { + --box-shadow: #{$popover-ios-desktop-box-shadow}; +} + .popover-content { @include border-radius($popover-ios-border-radius); } +:host(.popover-desktop) .popover-content { + border: #{$popover-ios-desktop-border}; +} + // Popover Arrow // ----------------------------------------- @@ -55,6 +63,32 @@ top: -6px; } +:host(.popover-side-left) .popover-arrow { + transform: rotate(90deg); +} + +:host(.popover-side-right) .popover-arrow { + transform: rotate(-90deg); +} + +:host(.popover-side-top) .popover-arrow { + transform: rotate(180deg); +} + +:host(.popover-side-start) .popover-arrow { + @include rtl() { + transform: rotate(-90deg); + } + transform: rotate(90deg); +} + +:host(.popover-side-end) .popover-arrow { + @include rtl() { + transform: rotate(90deg); + } + transform: rotate(-90deg); +} + // Translucent Popover // ----------------------------------------- @@ -64,4 +98,4 @@ background: $popover-ios-translucent-background-color; backdrop-filter: $popover-ios-translucent-filter; } -} \ No newline at end of file +} diff --git a/core/src/components/popover/popover.ios.vars.scss b/core/src/components/popover/popover.ios.vars.scss index f830bc623f..84330d3b6e 100644 --- a/core/src/components/popover/popover.ios.vars.scss +++ b/core/src/components/popover/popover.ios.vars.scss @@ -23,3 +23,9 @@ $popover-ios-translucent-background-color: rgba($background-color-rgb /// @prop - Filter of the translucent popover $popover-ios-translucent-filter: saturate(180%) blur(20px) !default; + +/// $prop - Box shadow of popover on desktop +$popover-ios-desktop-box-shadow: 0px 4px 16px 0px rgba(0, 0, 0, 0.12) !default; + +/// $prop - Border of popover content on desktop +$popover-ios-desktop-border: 0.5px solid $background-color-step-100 !default; diff --git a/core/src/components/popover/popover.scss b/core/src/components/popover/popover.scss index 512de38dbb..c58235bcfc 100644 --- a/core/src/components/popover/popover.scss +++ b/core/src/components/popover/popover.scss @@ -17,12 +17,17 @@ * @prop --max-height: Maximum height of the popover * * @prop --backdrop-opacity: Opacity of the backdrop + * + * @prop --offset-x: The amount to move the popover by on the x-axis + * @prop --offset-y: The amount to move the popover by on the y-axis */ --background: #{$popover-background-color}; --min-width: 0; --min-height: 0; --max-width: auto; --height: auto; + --offset-x: 0px; + --offset-y: 0px; @include position(0, 0, 0, 0); @@ -84,3 +89,29 @@ --ion-safe-area-left: 0px; } +// Nested Popovers +// -------------------------------------------------- +:host(.popover-nested.popover-side-left) { + --offset-x: 5px; +} + +:host(.popover-nested.popover-side-right) { + --offset-x: -5px; +} + +:host(.popover-nested.popover-side-start) { + --offset-x: 5px; + + @include rtl() { + --offset-x: -5px; + } +} + +:host(.popover-nested.popover-side-end) { + --offset-x: -5px; + + @include rtl() { + --offset-x: 5px; + } +} + diff --git a/core/src/components/popover/popover.tsx b/core/src/components/popover/popover.tsx index c645a71d39..f52a676c4f 100644 --- a/core/src/components/popover/popover.tsx +++ b/core/src/components/popover/popover.tsx @@ -1,10 +1,11 @@ import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Method, Prop, State, Watch, h } from '@stencil/core'; import { getIonMode } from '../../global/ionic-global'; -import { AnimationBuilder, ComponentProps, ComponentRef, FrameworkDelegate, OverlayEventDetail, OverlayInterface } from '../../interface'; +import { AnimationBuilder, ComponentProps, ComponentRef, FrameworkDelegate, OverlayEventDetail, OverlayInterface, PopoverSize, PositionAlign, PositionReference, PositionSide, TriggerAction } from '../../interface'; import { attachComponent, detachComponent } from '../../utils/framework-delegate'; -import { raf } from '../../utils/helpers'; -import { BACKDROP, dismiss, eventMethod, prepareOverlay, present } from '../../utils/overlays'; +import { addEventListener, raf } from '../../utils/helpers'; +import { BACKDROP, dismiss, eventMethod, focusFirstDescendant, prepareOverlay, present } from '../../utils/overlays'; +import { isPlatform } from '../../utils/platform'; import { getClassMap } from '../../utils/theme'; import { deepReady } from '../../utils/transition'; @@ -12,6 +13,7 @@ import { iosEnterAnimation } from './animations/ios.enter'; import { iosLeaveAnimation } from './animations/ios.leave'; import { mdEnterAnimation } from './animations/md.enter'; import { mdLeaveAnimation } from './animations/md.leave'; +import { configureDismissInteraction, configureKeyboardInteraction, configureTriggerInteraction } from './utils'; const CoreDelegate = () => { let Cmp: any; @@ -55,10 +57,18 @@ const CoreDelegate = () => { export class Popover implements ComponentInterface, OverlayInterface { private usersElement?: HTMLElement; + private triggerEl?: HTMLElement | null; + private parentPopover: HTMLIonPopoverElement | null = null; private popoverIndex = popoverIds++; private popoverId?: string; private coreDelegate: FrameworkDelegate = CoreDelegate(); private currentTransition?: Promise; + private destroyTriggerInteraction?: () => void; + private destroyKeyboardInteraction?: () => void; + private destroyDismissInteraction?: () => void; + + private triggerEv?: Event; + private focusDescendantOnPresent = false; lastFocus?: HTMLElement; @@ -140,6 +150,70 @@ export class Popover implements ComponentInterface, OverlayInterface { */ @Prop() animated = true; + /** + * Describes what kind of interaction with the trigger that + * should cause the popover to open. Does not apply when the `trigger` + * property is `undefined`. + * If `'click'`, the popover will be presented when the trigger is left clicked. + * If `'hover'`, the popover will be presented when a pointer hovers over the trigger. + * If `'context-menu'`, the popover will be presented when the trigger is right + * clicked on desktop and long pressed on mobile. This will also prevent your + * device's normal context menu from appearing. + */ + @Prop() triggerAction: TriggerAction = 'click'; + + /** + * An ID corresponding to the trigger element that + * causes the popover to open. Use the `trigger-action` + * property to customize the interaction that results in + * the popover opening. + */ + @Prop() trigger: string | undefined; + + /** + * Describes how to calculate the popover width. + * If `'cover'`, the popover width will match the width of the trigger. + * If `'auto'`, the popover width will be determined by the content in + * the popover. + */ + @Prop() size: PopoverSize = 'auto'; + + /** + * If `true`, the popover will be automatically + * dismissed when the content has been clicked. + */ + @Prop() dismissOnSelect = false; + + /** + * Describes what to position the popover relative to. + * If `'trigger'`, the popover will be positioned relative + * to the trigger button. If passing in an event, this is + * determined via event.target. + * If `'event'`, the popover will be positioned relative + * to the x/y coordinates of the trigger action. If passing + * in an event, this is determined via event.clientX and event.clientY. + */ + @Prop() reference: PositionReference = 'trigger'; + + /** + * Describes which side of the `reference` point to position + * the popover on. The `'start'` and `'end'` values are RTL-aware, + * and the `'left'` and `'right'` values are not. + */ + @Prop() side: PositionSide = 'bottom'; + + /** + * Describes how to align the popover content with the `reference` point. + */ + @Prop() alignment: PositionAlign = 'center'; + + /** + * If `true`, the popover will display an arrow + * that points at the `reference` when running in `ios` mode + * on mobile. Does not apply in `md` mode or on desktop. + */ + @Prop() arrow = true; + /** * If `true`, the popover will open. If `false`, the popover will close. * Use this if you need finer grained control over presentation, otherwise @@ -149,6 +223,12 @@ export class Popover implements ComponentInterface, OverlayInterface { */ @Prop() isOpen = false; + @Watch('trigger') + @Watch('triggerAction') + onTriggerChange() { + this.configureTriggerInteraction(); + } + @Watch('isOpen') onIsOpenChange(newValue: boolean, oldValue: boolean) { if (newValue === true && oldValue === false) { @@ -212,16 +292,48 @@ export class Popover implements ComponentInterface, OverlayInterface { * not assign the default incrementing ID. */ this.popoverId = (this.el.hasAttribute('id')) ? this.el.getAttribute('id')! : `ion-popover-${this.popoverIndex}`; + + this.parentPopover = this.el.closest(`ion-popover:not(#${this.popoverId})`) as HTMLIonPopoverElement | null; } componentDidLoad() { + const { parentPopover, isOpen } = this; + /** * If popover was rendered with isOpen="true" * then we should open popover immediately. */ - if (this.isOpen === true) { + if (isOpen === true) { raf(() => this.present()); } + + if (parentPopover) { + addEventListener(parentPopover, 'ionPopoverWillDismiss', () => { + this.dismiss(undefined, undefined, false); + }); + } + + this.configureTriggerInteraction(); + } + + /** + * When opening a popover from a trigger, we should not be + * modifying the `event` prop from inside the component. + * Additionally, when pressing the "Right" arrow key, we need + * to shift focus to the first descendant in the newly presented + * popover. + * + * @internal + */ + @Method() + async presentFromTrigger(event?: any, focusDescendant = false) { + this.triggerEv = event; + this.focusDescendantOnPresent = focusDescendant; + + await this.present(); + + this.triggerEv = undefined; + this.focusDescendantOnPresent = false; } /** @@ -260,11 +372,31 @@ export class Popover implements ComponentInterface, OverlayInterface { this.usersElement = await attachComponent(delegate, this.el, this.component, ['popover-viewport'], data, this.inline); await deepReady(this.usersElement); - this.currentTransition = present(this, 'popoverEnter', iosEnterAnimation, mdEnterAnimation, this.event); + this.configureKeyboardInteraction(); + this.configureDismissInteraction(); + + this.currentTransition = present(this, 'popoverEnter', iosEnterAnimation, mdEnterAnimation, { + event: this.event || this.triggerEv, + size: this.size, + trigger: this.triggerEl, + reference: this.reference, + side: this.side, + align: this.alignment + }); await this.currentTransition; this.currentTransition = undefined; + + /** + * If popover is nested and was + * presented using the "Right" arrow key, + * we need to move focus to the first + * descendant inside of the popover. + */ + if (this.focusDescendantOnPresent) { + focusFirstDescendant(this.el, this.el); + } } /** @@ -272,9 +404,11 @@ export class Popover implements ComponentInterface, OverlayInterface { * * @param data Any data to emit in the dismiss events. * @param role The role of the element that is dismissing the popover. For example, 'cancel' or 'backdrop'. + * @param dismissParentPopover If `true`, dismissing this popover will also dismiss + * a parent popover if this popover is nested. Defaults to `true`. */ @Method() - async dismiss(data?: any, role?: string): Promise { + async dismiss(data?: any, role?: string, dismissParentPopover = true): Promise { /** * When using an inline popover * and presenting a popover it is possible to @@ -287,9 +421,22 @@ export class Popover implements ComponentInterface, OverlayInterface { await this.currentTransition; } + const { destroyKeyboardInteraction, destroyDismissInteraction } = this; + if (dismissParentPopover && this.parentPopover) { + this.parentPopover.dismiss(data, role, dismissParentPopover) + } + this.currentTransition = dismiss(this, data, role, 'popoverLeave', iosLeaveAnimation, mdLeaveAnimation, this.event); const shouldDismiss = await this.currentTransition; if (shouldDismiss) { + if (destroyKeyboardInteraction) { + destroyKeyboardInteraction(); + this.destroyKeyboardInteraction = undefined; + } + if (destroyDismissInteraction) { + destroyDismissInteraction(); + this.destroyDismissInteraction = undefined; + } await detachComponent(this.delegate, this.usersElement); } @@ -298,6 +445,14 @@ export class Popover implements ComponentInterface, OverlayInterface { return shouldDismiss; } + /** + * @internal + */ + @Method() + async getParentPopover(): Promise { + return this.parentPopover; + } + /** * Returns a promise that resolves when the popover did dismiss. */ @@ -338,9 +493,46 @@ export class Popover implements ComponentInterface, OverlayInterface { } } + private configureTriggerInteraction = () => { + const { trigger, triggerAction, el, destroyTriggerInteraction } = this; + + if (destroyTriggerInteraction) { + destroyTriggerInteraction(); + } + + const triggerEl = this.triggerEl = (trigger !== undefined) ? document.getElementById(trigger) : null; + if (!triggerEl) { return; } + + this.destroyTriggerInteraction = configureTriggerInteraction(triggerEl, triggerAction, el); + } + + private configureKeyboardInteraction = () => { + const { destroyKeyboardInteraction, el } = this; + + if (destroyKeyboardInteraction) { + destroyKeyboardInteraction(); + } + + this.destroyKeyboardInteraction = configureKeyboardInteraction(el); + } + + private configureDismissInteraction = () => { + const { destroyDismissInteraction, parentPopover, triggerAction, triggerEl, el } = this; + + if (!parentPopover || !triggerEl) { return; } + + if (destroyDismissInteraction) { + destroyDismissInteraction(); + } + + this.destroyDismissInteraction = configureDismissInteraction(triggerEl, triggerAction, el, parentPopover); + } + render() { const mode = getIonMode(this); - const { onLifecycle, presented, popoverId } = this; + const { onLifecycle, popoverId, parentPopover, dismissOnSelect, presented, side, arrow } = this; + const desktop = isPlatform('desktop'); + const enableArrow = arrow && !parentPopover && !desktop; return ( - + {!parentPopover && } -
-
-
+
this.dismiss() : undefined} + > + {enableArrow &&
} +
diff --git a/core/src/components/popover/readme.md b/core/src/components/popover/readme.md index 45acd59e5e..63b2b189e5 100644 --- a/core/src/components/popover/readme.md +++ b/core/src/components/popover/readme.md @@ -3,6 +3,83 @@ A Popover is a dialog that appears on top of the current page. It can be used for anything, but generally it is used for overflow actions that don't fit in the navigation bar. There are two ways to use `ion-popover`: inline or via the `popoverController`. Each method comes with different considerations, so be sure to use the approach that best fits your use case. +<<<<<<< HEAD + +## Inline Popovers + +`ion-popover` can be used by writing the component directly in your template. This reduces the number of handlers you need to wire up in order to present the popover. See [Usage](#usage) for an example of how to write a popover inline. + +When using `ion-popover` with Angular, React, or Vue, the component you pass in will be destroyed when the popover is dismissed. As this functionality is provided by the JavaScript framework, using `ion-popover` without a JavaScript framework will not destroy the component you passed in. If this is a needed functionality, we recommend using the `popoverController` instead. + +### Angular + +Since the component you passed in needs to be created when the popover is presented and destroyed when the popover is dismissed, we are unable to project the content using `` internally. Instead, we use `` which expects an `` to be passed in. As a result, when passing in your component you will need to wrap it in an ``: + +```html + + + + + +``` + +### When to use + +Using a popover inline is useful when you do not want to explicitly wire up click events to open the popover. For example, you can use the `trigger` property to designate a button that should present the popover when clicked. You can also use the `trigger-action` property to customize whether the popover should be presented when the trigger is left clicked, right clicked, or hovered over. + +If you need fine grained control over when the popover is presented and dismissed, we recommend you use the `popoverController`. + +## Controller Popovers + +`ion-popover` can also be presented programmatically by using the `popoverController` imported from Ionic Framework. This allows you to have complete control over when a popover is presented above and beyond the customization that inline popovers give you. See [Usage](#usage) for an example of how to use the `popoverController`. + +### When to use + +We typically recommend that you write your popovers inline as it streamlines the amount of code in your application. You should only use the `popoverController` for complex use cases where writing a popover inline is impractical. When using a controller, your popover is not created ahead of time, so properties such as `trigger` and `trigger-action` are not applicable here. In addition, nested popovers are not compatible with the controller approach because the popover is automatically added to the root of your application when the `create` method is called. + +## Interfaces + +Below you will find all of the options available to you when using the `popoverController`. These options should be supplied when calling `popoverController.create()`. + +```typescript +interface PopoverOptions { + component: any; + componentProps?: { [key: string]: any }; + showBackdrop?: boolean; + backdropDismiss?: boolean; + translucent?: boolean; + cssClass?: string | string[]; + event?: Event; + animated?: boolean; + + mode?: 'ios' | 'md'; + keyboardClose?: boolean; + id?: string; + + enterAnimation?: AnimationBuilder; + leaveAnimation?: AnimationBuilder; + + size?: PopoverSize; + dismissOnSelect?: boolean; + reference?: PositionReference; + side?: PositionSide; + align?: PositionAlign; +} +``` + +## Types + +Below you will find all of the custom types for `ion-popover`: + +```typescript +type PopoverSize = 'cover' | 'auto'; +type TriggerAction = 'click' | 'hover' | 'context-menu'; +type PositionReference = 'trigger' | 'event'; +type PositionSide = 'top' | 'right' | 'bottom' | 'left' | 'start' | 'end'; +type PositionAlign = 'start' | 'center' | 'end'; +``` + +======= ## Inline Popovers @@ -62,12 +139,13 @@ interface PopoverOptions { leaveAnimation?: AnimationBuilder; } ``` +>>>>>>> origin/next ## Customization Popover uses scoped encapsulation, which means it will automatically scope its CSS by appending each of the styles with an additional class at runtime. Overriding scoped selectors in CSS requires a [higher specificity](https://developer.mozilla.org/en-US/docs/Web/CSS/Specificity) selector. -We recommend passing a custom class to `cssClass` in the `create` method and using that to add custom styles to the host and inner elements. This property can also accept multiple classes separated by spaces. View the [Usage](#usage) section for an example of how to pass a class using `cssClass`. +We recommend setting a custom class on the host element if writing a popover inline or supplying a class to the `cssClass` option if using the `popoverController` and using that to add custom styles to the host and inner elements. The `cssClass` option can also accept multiple classes separated by spaces. View the [Usage](#usage) section for an example of how to pass a class using `cssClass`. ```css /* DOES NOT WORK - not specific enough */ @@ -91,6 +169,78 @@ Any of the defined [CSS Custom Properties](#css-custom-properties) can be used t > If you are building an Ionic Angular app, the styles need to be added to a global stylesheet file. Read [Style Placement](#style-placement) in the Angular section below for more information. +## Triggers + +A trigger for an `ion-popover` is the element that will open a popover when interacted with. The interaction behavior can be customized by setting the `trigger-action` property. The following example shows how to create a right click menu using `trigger` and `trigger-action`. Note that `trigger-action="context-menu"` will prevent your system's default context menu from opening. + +```html +Right click me! + + + + ... + + + +``` + +> Triggers are not applicable when using the `popoverController` because the `ion-popover` is not created ahead of time. +## Positioning + +### Reference + +When presenting a popover, Ionic Framework needs a reference point to present the popover relative to. With `reference="event"`, the popover will be presented relative to the x-y coordinates of the pointer event that was dispatched on your trigger element. With `reference="trigger"`, the popover will be presented relative to the bounding box of your trigger element. + +### Side + +Regardless of what you choose for your reference point, you can position a popover to the `top`, `right`, `left`, or `bottom` of your reference point by using the `side` property. You can also use the `start` or `end` values if you would like the side to switch based on LTR or RTL modes. + +### Alignment + +The `alignment` property allows you to line up an edge of your popover with a corresponding edge on your trigger element. The exact edge that is used depends on the value of the `side` property. + +### Offsets + +If you need finer grained control over the positioning of your popover you can use the `--offset-x` and `--offset-y` CSS Variables. For example, `--offset-x: 10px` will move your popover content to the right by `10px`. + +## Sizing + +When making dropdown menus, you may want to have the width of the popover match the width of the trigger element. Doing this without knowing the trigger width ahead of time is tricky. You can set the `size` property to `'cover'` and Ionic Framework will ensure that the width of the popover matches the width of your trigger element. If you are using the `popoverController`, you must provide an event via the `event` option and Ionic Framework will use `event.target` as the reference element. + +## Nested Popovers + +When using `ion-popover` inline, you can nested popovers to create nested dropdown menus. When doing this, only the backdrop on the first popover will appear so that the screen does not get progressively darker as you open more popovers. See the [Usage](./#usage) section for an example on how to write a nested popover. + +You can use the `dismissOnSelect` property to automatically close the popover when the popover content has been clicked. This behavior does not apply when clicking a trigger element for another popover. + +> Nested popovers cannot be created when using the `popoverController` because the popover is automatically added to the root of your application when the `create` method is called. + +## Accessibility + +### Keyboard Navigation + +`ion-popover` has basic keyboard support for navigating between focusable elements inside of the popover. The following table details what each key does: + +| Key | Function | +| ------------------ | ------------------------------------------------------------ | +| `Tab` | Moves focus to the next focusable element. | +| `Shift` + `Tab` | Moves focus to the previous focusable element. | +| `Esc` | Closes the popover. | +| `Space` or `Enter` | Clicks the focusable element. | + + +`ion-popover` has full arrow key support for navigating between `ion-item` elements with the `button` property. The most common use case for this is as a dropdown menu in a desktop-focused application. In addition to the basic keyboard support, the following table details arrow key support for dropdown menus: + +| Key | Function | +| ------------------ | -------------------------------------------------------------- | +| `ArrowUp` | Moves focus to the previous focusable element. | +| `ArrowDown` | Moves focus to the next focusable element. | +| `ArrowLeft` | When used in a child popover, closes the popover and returns focus to the parent popover. | +| `Space`, `Enter`, and `ArrowRight` | When focusing a trigger element, opens the associated popover. | + @@ -417,20 +567,28 @@ export default defineComponent({ ## Properties -| Property | Attribute | Description | Type | Default | -| ----------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | ----------- | -| `animated` | `animated` | If `true`, the popover will animate. | `boolean` | `true` | -| `backdropDismiss` | `backdrop-dismiss` | If `true`, the popover will be dismissed when the backdrop is clicked. | `boolean` | `true` | -| `component` | `component` | The component to display inside of the popover. You only need to use this if you are not using a JavaScript framework. Otherwise, you can just slot your component inside of `ion-popover`. | `Function \| HTMLElement \| null \| string \| undefined` | `undefined` | -| `componentProps` | -- | The data to pass to the popover component. You only need to use this if you are not using a JavaScript framework. Otherwise, you can just set the props directly on your component. | `undefined \| { [key: string]: any; }` | `undefined` | -| `enterAnimation` | -- | Animation to use when the popover is presented. | `((baseEl: any, opts?: any) => Animation) \| undefined` | `undefined` | -| `event` | `event` | The event to pass to the popover animation. | `any` | `undefined` | -| `isOpen` | `is-open` | If `true`, the popover will open. If `false`, the popover will close. Use this if you need finer grained control over presentation, otherwise just use the popoverController or the `trigger` property. Note: `isOpen` will not automatically be set back to `false` when the popover dismisses. You will need to do that in your code. | `boolean` | `false` | -| `keyboardClose` | `keyboard-close` | If `true`, the keyboard will be automatically dismissed when the overlay is presented. | `boolean` | `true` | -| `leaveAnimation` | -- | Animation to use when the popover is dismissed. | `((baseEl: any, opts?: any) => Animation) \| undefined` | `undefined` | -| `mode` | `mode` | The mode determines which platform styles to use. | `"ios" \| "md"` | `undefined` | -| `showBackdrop` | `show-backdrop` | If `true`, a backdrop will be displayed behind the popover. | `boolean` | `true` | -| `translucent` | `translucent` | If `true`, the popover will be translucent. Only applies when the mode is `"ios"` and the device supports [`backdrop-filter`](https://developer.mozilla.org/en-US/docs/Web/CSS/backdrop-filter#Browser_compatibility). | `boolean` | `false` | +| Property | Attribute | Description | Type | Default | +| ----------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | ----------- | +| `alignment` | `alignment` | Describes how to align the popover content with the `reference` point. | `"center" \| "end" \| "start"` | `'center'` | +| `animated` | `animated` | If `true`, the popover will animate. | `boolean` | `true` | +| `arrow` | `arrow` | If `true`, the popover will display an arrow that points at the `reference` when running in `ios` mode on mobile. Does not apply in `md` mode or on desktop. | `boolean` | `true` | +| `backdropDismiss` | `backdrop-dismiss` | If `true`, the popover will be dismissed when the backdrop is clicked. | `boolean` | `true` | +| `component` | `component` | The component to display inside of the popover. You only need to use this if you are not using a JavaScript framework. Otherwise, you can just slot your component inside of `ion-popover`. | `Function \| HTMLElement \| null \| string \| undefined` | `undefined` | +| `componentProps` | -- | The data to pass to the popover component. You only need to use this if you are not using a JavaScript framework. Otherwise, you can just set the props directly on your component. | `undefined \| { [key: string]: any; }` | `undefined` | +| `dismissOnSelect` | `dismiss-on-select` | If `true`, the popover will be automatically dismissed when the content has been clicked. | `boolean` | `false` | +| `enterAnimation` | -- | Animation to use when the popover is presented. | `((baseEl: any, opts?: any) => Animation) \| undefined` | `undefined` | +| `event` | `event` | The event to pass to the popover animation. | `any` | `undefined` | +| `isOpen` | `is-open` | If `true`, the popover will open. If `false`, the popover will close. Use this if you need finer grained control over presentation, otherwise just use the popoverController or the `trigger` property. Note: `isOpen` will not automatically be set back to `false` when the popover dismisses. You will need to do that in your code. | `boolean` | `false` | +| `keyboardClose` | `keyboard-close` | If `true`, the keyboard will be automatically dismissed when the overlay is presented. | `boolean` | `true` | +| `leaveAnimation` | -- | Animation to use when the popover is dismissed. | `((baseEl: any, opts?: any) => Animation) \| undefined` | `undefined` | +| `mode` | `mode` | The mode determines which platform styles to use. | `"ios" \| "md"` | `undefined` | +| `reference` | `reference` | Describes what to position the popover relative to. If `'trigger'`, the popover will be positioned relative to the trigger button. If passing in an event, this is determined via event.target. If `'event'`, the popover will be positioned relative to the x/y coordinates of the trigger action. If passing in an event, this is determined via event.clientX and event.clientY. | `"event" \| "trigger"` | `'trigger'` | +| `showBackdrop` | `show-backdrop` | If `true`, a backdrop will be displayed behind the popover. | `boolean` | `true` | +| `side` | `side` | Describes which side of the `reference` point to position the popover on. The `'start'` and `'end'` values are RTL-aware, and the `'left'` and `'right'` values are not. | `"bottom" \| "end" \| "left" \| "right" \| "start" \| "top"` | `'bottom'` | +| `size` | `size` | Describes how to calculate the popover width. If `'cover'`, the popover width will match the width of the trigger. If `'auto'`, the popover width will be determined by the content in the popover. | `"auto" \| "cover"` | `'auto'` | +| `translucent` | `translucent` | If `true`, the popover will be translucent. Only applies when the mode is `"ios"` and the device supports [`backdrop-filter`](https://developer.mozilla.org/en-US/docs/Web/CSS/backdrop-filter#Browser_compatibility). | `boolean` | `false` | +| `trigger` | `trigger` | An ID corresponding to the trigger element that causes the popover to open. Use the `trigger-action` property to customize the interaction that results in the popover opening. | `string \| undefined` | `undefined` | +| `triggerAction` | `trigger-action` | Describes what kind of interaction with the trigger that should cause the popover to open. Does not apply when the `trigger` property is `undefined`. If `'click'`, the popover will be presented when the trigger is left clicked. If `'hover'`, the popover will be presented when a pointer hovers over the trigger. If `'context-menu'`, the popover will be presented when the trigger is right clicked on desktop and long pressed on mobile. This will also prevent your device's normal context menu from appearing. | `"click" \| "context-menu" \| "hover"` | `'click'` | ## Events @@ -449,7 +607,7 @@ export default defineComponent({ ## Methods -### `dismiss(data?: any, role?: string | undefined) => Promise` +### `dismiss(data?: any, role?: string | undefined, dismissParentPopover?: boolean) => Promise` Dismiss the popover overlay after it has been presented. @@ -508,17 +666,19 @@ Type: `Promise` ## CSS Custom Properties -| Name | Description | -| -------------------- | ----------------------------- | -| `--backdrop-opacity` | Opacity of the backdrop | -| `--background` | Background of the popover | -| `--box-shadow` | Box shadow of the popover | -| `--height` | Height of the popover | -| `--max-height` | Maximum height of the popover | -| `--max-width` | Maximum width of the popover | -| `--min-height` | Minimum height of the popover | -| `--min-width` | Minimum width of the popover | -| `--width` | Width of the popover | +| Name | Description | +| -------------------- | ----------------------------------------------- | +| `--backdrop-opacity` | Opacity of the backdrop | +| `--background` | Background of the popover | +| `--box-shadow` | Box shadow of the popover | +| `--height` | Height of the popover | +| `--max-height` | Maximum height of the popover | +| `--max-width` | Maximum width of the popover | +| `--min-height` | Minimum height of the popover | +| `--min-width` | Minimum width of the popover | +| `--offset-x` | The amount to move the popover by on the x-axis | +| `--offset-y` | The amount to move the popover by on the y-axis | +| `--width` | Width of the popover | ## Dependencies diff --git a/core/src/components/popover/test/arrow/e2e.ts b/core/src/components/popover/test/arrow/e2e.ts new file mode 100644 index 0000000000..2203c3c331 --- /dev/null +++ b/core/src/components/popover/test/arrow/e2e.ts @@ -0,0 +1,62 @@ +import { newE2EPage } from '@stencil/core/testing'; + +test('popover - arrow side: top', async () => { + await testPopover('top'); +}); + +test('popover - arrow side: right', async () => { + await testPopover('right'); +}); + +test('popover - arrow side: bottom', async () => { + await testPopover('bottom'); +}); + +test('popover - arrow side: left', async () => { + await testPopover('left'); +}); + +test('popover - arrow side: start', async () => { + await testPopover('start'); +}); + +test('popover - arrow side: end', async () => { + await testPopover('end'); +}); + +test('popover - arrow side: start, rtl', async () => { + await testPopover('start', true); +}); + +test('popover - arrow side: end, rtl', async () => { + await testPopover('end', true); +}); + + +const testPopover = async (side, isRTL = false) => { + const rtl = isRTL ? '&rtl=true' : ''; + const page = await newE2EPage({ url: `/src/components/popover/test/arrow?ionic:_testing=true${rtl}` }); + + const POPOVER_CLASS = `${side}-popover`; + const TRIGGER_ID = `${side}-trigger`; + const screenshotCompares = []; + + const trigger = await page.find(`#${TRIGGER_ID}`); + + await page.evaluate((TRIGGER_ID) => { + const trigger = document.querySelector(`#${TRIGGER_ID}`); + trigger.scrollIntoView({ block: 'center' }); + }, TRIGGER_ID); + + trigger.click(); + + await page.waitForSelector(`.${POPOVER_CLASS}`); + const popover = await page.find(`.${POPOVER_CLASS}`); + await popover.waitForVisible(); + + screenshotCompares.push(await page.compareScreenshot()); + + for (const screenshotCompare of screenshotCompares) { + expect(screenshotCompare).toMatchScreenshot(); + } +} diff --git a/core/src/components/popover/test/arrow/index.html b/core/src/components/popover/test/arrow/index.html new file mode 100644 index 0000000000..1b5fa13437 --- /dev/null +++ b/core/src/components/popover/test/arrow/index.html @@ -0,0 +1,158 @@ + + + + + + Popover - Arrow + + + + + + + + + + + + + Popover - Arrow + + + + +
+
+

Top

+ Click to Open + + + Hello World + + +
+
+

Right

+ Click to Open + + + Hello World + + +
+
+

Bottom

+ Click to Open + + + Hello World + + +
+
+

Left

+ Click to Open + + + Hello World + + +
+
+

Start

+ Click to Open + + + Hello World + + +
+
+

End

+ Click to Open + + + Hello World + + +
+
+
+ +
+ + + + + diff --git a/core/src/components/popover/test/dismissOnSelect/e2e.ts b/core/src/components/popover/test/dismissOnSelect/e2e.ts new file mode 100644 index 0000000000..7c28dac0ff --- /dev/null +++ b/core/src/components/popover/test/dismissOnSelect/e2e.ts @@ -0,0 +1,53 @@ +import { newE2EPage } from '@stencil/core/testing'; + +test('should not dismiss a popover when clicking a hover trigger', async () => { + const page = await newE2EPage({ url: `/src/components/popover/test/dismissOnSelect?ionic:_testing=true` }); + + const POPOVER_CLASS = 'hover-trigger-popover'; + const TRIGGER_ID = 'hover-trigger'; + const screenshotCompares = []; + + await page.click(`#${TRIGGER_ID}`); + + await page.waitForSelector(`.${POPOVER_CLASS}`); + const popover = await page.find(`.${POPOVER_CLASS}`); + await popover.waitForVisible(); + + await page.hover('#more-hover-trigger'); + await page.click('#more-hover-trigger'); + + const isVisible = await popover.isVisible(); + expect(isVisible).toBe(true); + + screenshotCompares.push(await page.compareScreenshot()); + + for (const screenshotCompare of screenshotCompares) { + expect(screenshotCompare).toMatchScreenshot(); + } +}); + +test('should not dismiss a popover when clicking a click trigger', async () => { + const page = await newE2EPage({ url: `/src/components/popover/test/dismissOnSelect?ionic:_testing=true` }); + + const POPOVER_CLASS = 'click-trigger-popover'; + const TRIGGER_ID = 'click-trigger'; + const screenshotCompares = []; + + await page.click(`#${TRIGGER_ID}`); + + await page.waitForSelector(`.${POPOVER_CLASS}`); + const popover = await page.find(`.${POPOVER_CLASS}`); + await popover.waitForVisible(); + + await page.hover('#more-click-trigger'); + await page.click('#more-click-trigger'); + + const isVisible = await popover.isVisible(); + expect(isVisible).toBe(true); + + screenshotCompares.push(await page.compareScreenshot()); + + for (const screenshotCompare of screenshotCompares) { + expect(screenshotCompare).toMatchScreenshot(); + } +}); diff --git a/core/src/components/popover/test/dismissOnSelect/index.html b/core/src/components/popover/test/dismissOnSelect/index.html new file mode 100644 index 0000000000..bed8ab51b4 --- /dev/null +++ b/core/src/components/popover/test/dismissOnSelect/index.html @@ -0,0 +1,158 @@ + + + + + + Popover - Dismiss On Select + + + + + + + + + + + + Popover - Dismiss On Select + + + + +
+
+

Dismiss On Select - Click

+ Click to Open + + + + + Copy + + + Cut + + + + Paste + + + + More + + + + + + My Item + + + + + + +
+
+

Dismiss On Select - Hover

+ Click to Open + + + + + Copy + + + Cut + + + + Paste + + + + More + + + + + + My Item + + + + + + +
+
+
+ +
+ + + + + diff --git a/core/src/components/popover/test/isOpen/e2e.ts b/core/src/components/popover/test/isOpen/e2e.ts new file mode 100644 index 0000000000..2d23c08cf7 --- /dev/null +++ b/core/src/components/popover/test/isOpen/e2e.ts @@ -0,0 +1,47 @@ +import { newE2EPage } from '@stencil/core/testing'; + +test('should open the popover', async () => { + const page = await newE2EPage({ url: '/src/components/popover/test/isOpen?ionic:_testing=true' }); + + const screenshotCompares = []; + + const trigger = await page.find('#default'); + trigger.click(); + + await page.waitForSelector('ion-popover'); + const popover = await page.find('ion-popover'); + await popover.waitForVisible(); + + screenshotCompares.push(await page.compareScreenshot()); + + for (const screenshotCompare of screenshotCompares) { + expect(screenshotCompare).toMatchScreenshot(); + } +}); + +test('should open the popover then close after a timeout', async () => { + const page = await newE2EPage({ url: '/src/components/popover/test/isOpen?ionic:_testing=true' }); + + const screenshotCompares = []; + + const ionPopoverDidDismiss = await page.spyOnEvent('ionPopoverDidDismiss'); + + const trigger = await page.find('#timeout'); + trigger.click(); + + await page.waitForSelector('ion-popover'); + const popover = await page.find('ion-popover'); + await popover.waitForVisible(); + + screenshotCompares.push(await page.compareScreenshot()); + + await ionPopoverDidDismiss.next(); + + await popover.waitForNotVisible(); + + screenshotCompares.push(await page.compareScreenshot()); + + for (const screenshotCompare of screenshotCompares) { + expect(screenshotCompare).toMatchScreenshot(); + } +}); diff --git a/core/src/components/popover/test/isOpen/index.html b/core/src/components/popover/test/isOpen/index.html new file mode 100644 index 0000000000..61e0ed21a4 --- /dev/null +++ b/core/src/components/popover/test/isOpen/index.html @@ -0,0 +1,82 @@ + + + + + Popover - isOpen + + + + + + + + + + + + Popover - isOpen + + + + +
+
+

Default

+ Open Popover +
+
+

Open, then close after 500ms

+ Open Popover +
+
+ + + + Hello World + + +
+
+ + + + diff --git a/core/src/components/popover/test/nested/index.html b/core/src/components/popover/test/nested/index.html new file mode 100644 index 0000000000..0034322143 --- /dev/null +++ b/core/src/components/popover/test/nested/index.html @@ -0,0 +1,354 @@ + + + + + + Popover - Nested + + + + + + + + + + + + Popover - Nested + + + + + + + + + Open + + + Open With + + + + + + + Preview (default) + + + + + + Figma + + + + Nova + + + + Sketch + + + + + + + + Move to Trash + + + + + + Get Info + + + + Rename + + + Duplicate + + + + + + Copy + + + Share + + + + + + Share File + + + + + + Mail + + + + Messages + + + + AirDrop + + + + + + + + + + + Click the icon above to see the nested menu. + + + + + diff --git a/core/src/components/popover/test/position/e2e.ts b/core/src/components/popover/test/position/e2e.ts new file mode 100644 index 0000000000..87c148f37f --- /dev/null +++ b/core/src/components/popover/test/position/e2e.ts @@ -0,0 +1,231 @@ +import { newE2EPage } from '@stencil/core/testing'; + +test('popover: position - side: top, alignment: start', async () => { + await testPopover('top', 'start'); +}); + +test('popover: position - side: top, alignment: center', async () => { + await testPopover('top', 'center'); +}); + +test('popover: position - side: top, alignment: end', async () => { + await testPopover('top', 'end'); +}); + +test('popover: position - side: right, alignment: start', async () => { + await testPopover('right', 'start'); +}); + +test('popover: position - side: right, alignment: center', async () => { + await testPopover('right', 'center'); +}); + +test('popover: position - side: right, alignment: end', async () => { + await testPopover('right', 'end'); +}); + +test('popover: position - side: bottom, alignment: start', async () => { + await testPopover('bottom', 'start'); +}); + +test('popover: position - side: bottom, alignment: center', async () => { + await testPopover('bottom', 'center'); +}); + +test('popover: position - side: bottom, alignment: end', async () => { + await testPopover('bottom', 'end'); +}); + +test('popover: position - side: left, alignment: start', async () => { + await testPopover('left', 'start'); +}); + +test('popover: position - side: left, alignment: center', async () => { + await testPopover('left', 'center'); +}); + +test('popover: position - side: left, alignment: end', async () => { + await testPopover('left', 'end'); +}); + +test('popover: position - side: start, alignment: start', async () => { + await testPopover('start', 'start'); +}); + +test('popover: position - side: start, alignment: center', async () => { + await testPopover('start', 'center'); +}); + +test('popover: position - side: start, alignment: end', async () => { + await testPopover('start', 'end'); +}); + +test('popover: position - side: end, alignment: start', async () => { + await testPopover('end', 'start'); +}); + +test('popover: position - side: end, alignment: center', async () => { + await testPopover('end', 'center'); +}); + +test('popover: position - side: end, alignment: end', async () => { + await testPopover('end', 'end'); +}); + +test('popover: position - side: start, alignment: start - rtl', async () => { + await testPopover('start', 'start', true); +}); + +test('popover: position - side: start, alignment: center - rtl', async () => { + await testPopover('start', 'center', true); +}); + +test('popover: position - side: start, alignment: end - rtl', async () => { + await testPopover('start', 'end', true); +}); + +test('popover: position - side: end, alignment: start - rtl', async () => { + await testPopover('end', 'start', true); +}); + +test('popover: position - side: end, alignment: center - rtl', async () => { + await testPopover('end', 'center', true); +}); + +test('popover: position - side: end, alignment: end - rtl', async () => { + await testPopover('end', 'end', true); +}); + + +const testPopover = async (side, alignment, isRTL = false) => { + const rtl = isRTL ? '&rtl=true' : ''; + const page = await newE2EPage({ url: `/src/components/popover/test/position?ionic:_testing=true${rtl}` }); + + const POPOVER_CLASS = `${side}-${alignment}-popover`; + const TRIGGER_ID = `${side}-${alignment}`; + const screenshotCompares = []; + + const trigger = await page.find(`#${TRIGGER_ID}`); + + await page.evaluate((TRIGGER_ID) => { + const trigger = document.querySelector(`#${TRIGGER_ID}`); + trigger.scrollIntoView({ block: 'center' }); + }, TRIGGER_ID); + + trigger.click(); + + await page.waitForSelector(`.${POPOVER_CLASS}`); + const popover = await page.find(`.${POPOVER_CLASS}`); + await popover.waitForVisible(); + + await testSideAndAlign(page, POPOVER_CLASS, TRIGGER_ID, side, alignment, isRTL); + + screenshotCompares.push(await page.compareScreenshot()); + + for (const screenshotCompare of screenshotCompares) { + expect(screenshotCompare).toMatchScreenshot(); + } +} + +const testSideAndAlign = async (page, popoverClass, triggerID, side, alignment, isRTL = false) => { + const popoverContentHandle = await page.evaluateHandle(`document.querySelector('.${popoverClass}').shadowRoot.querySelector('.popover-content')`); + const popoverBbox = await popoverContentHandle.boundingBox(); + + const triggerHandler = await page.$(`#${triggerID}`); + const triggerBbox = await triggerHandler.boundingBox(); + + const actualX = popoverBbox.x; + const actualY = popoverBbox.y; + + + let expectedX, expectedY; + + switch(side) { + case 'top': + expectedX = triggerBbox.x; + expectedY = triggerBbox.y - popoverBbox.height; + break; + case 'right': + expectedX = triggerBbox.x + triggerBbox.width; + expectedY = triggerBbox.y; + break; + case 'bottom': + expectedX = triggerBbox.x; + expectedY = triggerBbox.y + triggerBbox.height; + break; + case 'left': + expectedX = triggerBbox.x - popoverBbox.width; + expectedY = triggerBbox.y; + break; + case 'start': + expectedX = (isRTL) ? triggerBbox.x + triggerBbox.width : triggerBbox.x - popoverBbox.width; + expectedY = triggerBbox.y; + break; + case 'end': + expectedX = (isRTL) ? triggerBbox.x - popoverBbox.width : triggerBbox.x + triggerBbox.width; + expectedY = triggerBbox.y; + break; + default: + break; + } + + const alignmentAxis = (['top', 'bottom'].includes(side)) ? 'x' : 'y'; + switch(alignment) { + case 'center': + const centerAlign = getCenterAlign(side, triggerBbox, popoverBbox); + expectedX += centerAlign.left; + expectedY += centerAlign.top; + break; + case 'end': + const endAlign = getEndAlign(side, triggerBbox, popoverBbox); + expectedX += endAlign.left; + expectedY += endAlign.top; + break; + case 'start': + default: + break; + } + + expect(Math.abs(actualX - expectedX)).toBeLessThanOrEqual(2); + expect(Math.abs(actualY - expectedY)).toBeLessThanOrEqual(2); +} + +const getEndAlign = (side, triggerBbox, popoverBbox) => { + switch (side) { + case 'start': + case 'end': + case 'left': + case 'right': + return { + top: -(popoverBbox.height - triggerBbox.height), + left: 0 + } + case 'top': + case 'bottom': + default: + return { + top: 0, + left: -(popoverBbox.width - triggerBbox.width) + } + } +} + +const getCenterAlign = (side, triggerBbox, popoverBbox) => { + switch (side) { + case 'start': + case 'end': + case 'left': + case 'right': + return { + top: -((popoverBbox.height / 2) - (triggerBbox.height / 2)), + left: 0 + } + case 'top': + case 'bottom': + default: + return { + top: 0, + left: -((popoverBbox.width / 2) - (triggerBbox.width / 2)) + } + } +} diff --git a/core/src/components/popover/test/position/index.html b/core/src/components/popover/test/position/index.html new file mode 100644 index 0000000000..d6e1fb97ce --- /dev/null +++ b/core/src/components/popover/test/position/index.html @@ -0,0 +1,329 @@ + + + + + + Popover - Position + + + + + + + + + + + + Popover - Position + + + + +
+
+

Top, Start

+ Click to Open + + + +
+ +
+

Top, Center

+ Click to Open + + + +
+ +
+

Top, End

+ Click to Open + + + +
+ +
+

Right, Start

+ Click to Open + + + +
+ +
+

Right, Center

+ Click to Open + + + +
+ +
+

Right, End

+ Click to Open + + + +
+ +
+

Bottom, Start

+ Click to Open + + + +
+ +
+

Bottom, Center

+ Click to Open + + + +
+ +
+

Bottom, End

+ Click to Open + + + +
+ +
+

Left, Start

+ Click to Open + + + +
+ +
+

Left, Center

+ Click to Open + + + +
+ +
+

Left, End

+ Click to Open + + + +
+ +
+

Start, Start

+ Click to Open + + + +
+ +
+

Start, Center

+ Click to Open + + + +
+ +
+

Start, End

+ Click to Open + + + +
+ +
+

End, Start

+ Click to Open + + + +
+ +
+

End, Center

+ Click to Open + + + +
+ +
+

End, End

+ Click to Open + + + +
+
+
+ +
+ + + + + diff --git a/core/src/components/popover/test/reference/e2e.ts b/core/src/components/popover/test/reference/e2e.ts new file mode 100644 index 0000000000..86c3dcadce --- /dev/null +++ b/core/src/components/popover/test/reference/e2e.ts @@ -0,0 +1,58 @@ +import { newE2EPage } from '@stencil/core/testing'; + +test('should position popover relative to mouse click', async () => { + const page = await newE2EPage({ url: '/src/components/popover/test/reference?ionic:_testing=true' }); + + const screenshotCompares = []; + + const triggerHandler = await page.$('#event-trigger'); + const triggerBbox = await triggerHandler.boundingBox(); + + await page.mouse.click(triggerBbox.x, triggerBbox.y); + + await page.waitForSelector('.event-popover'); + const popover = await page.find('.event-popover'); + await popover.waitForVisible(); + + const popoverContentHandle = await page.evaluateHandle(`document.querySelector('.event-popover').shadowRoot.querySelector('.popover-content')`); + const popoverBbox = await popoverContentHandle.boundingBox(); + + // Give us some margin for subpixel rounding + expect(Math.abs(popoverBbox.x - triggerBbox.x)).toBeLessThanOrEqual(2); + expect(Math.abs(popoverBbox.y - triggerBbox.y)).toBeLessThanOrEqual(2); + + screenshotCompares.push(await page.compareScreenshot()); + + for (const screenshotCompare of screenshotCompares) { + expect(screenshotCompare).toMatchScreenshot(); + } +}); + +test('should position popover relative to trigger', async () => { + const page = await newE2EPage({ url: '/src/components/popover/test/reference?ionic:_testing=true' }); + + const screenshotCompares = []; + + const triggerHandler = await page.$('#trigger-trigger'); + const triggerBbox = await triggerHandler.boundingBox(); + + await page.mouse.click(triggerBbox.x + 5, triggerBbox.y + 5); + + await page.waitForSelector('.trigger-popover'); + const popover = await page.find('.trigger-popover'); + await popover.waitForVisible(); + + const popoverContentHandle = await page.evaluateHandle(`document.querySelector('.trigger-popover').shadowRoot.querySelector('.popover-content')`); + const popoverBbox = await popoverContentHandle.boundingBox(); + + // Give us some margin for subpixel rounding + const triggerBottom = triggerBbox.y + triggerBbox.height; + expect(Math.abs(popoverBbox.x - triggerBbox.x)).toBeLessThanOrEqual(2); + expect(Math.abs(popoverBbox.y - triggerBottom)).toBeLessThanOrEqual(2); + + screenshotCompares.push(await page.compareScreenshot()); + + for (const screenshotCompare of screenshotCompares) { + expect(screenshotCompare).toMatchScreenshot(); + } +}); diff --git a/core/src/components/popover/test/reference/index.html b/core/src/components/popover/test/reference/index.html new file mode 100644 index 0000000000..6e8aa43104 --- /dev/null +++ b/core/src/components/popover/test/reference/index.html @@ -0,0 +1,76 @@ + + + + + Popover - Reference + + + + + + + + + + + + Popover - Reference + + + + +
+
+

Event

+ Trigger + + + Popover Content + + +
+
+

Trigger

+ Trigger + + + Popover Content + + +
+
+
+
+ + diff --git a/core/src/components/popover/test/size/e2e.ts b/core/src/components/popover/test/size/e2e.ts new file mode 100644 index 0000000000..051414c248 --- /dev/null +++ b/core/src/components/popover/test/size/e2e.ts @@ -0,0 +1,51 @@ +import { newE2EPage } from '@stencil/core/testing'; + +test('should calculate popover width automatically', async () => { + const page = await newE2EPage({ url: '/src/components/popover/test/size?ionic:_testing=true' }); + + const screenshotCompares = []; + + const trigger = await page.find('#auto-trigger'); + trigger.click(); + + await page.waitForSelector('.auto-popover'); + const popover = await page.find('.auto-popover'); + await popover.waitForVisible(); + + const triggerHandler = await page.$('#auto-trigger'); + const popoverContentHandle = await page.evaluateHandle(`document.querySelector('.auto-popover').shadowRoot.querySelector('.popover-content')`); + const triggerBbox = await triggerHandler.boundingBox(); + const popoverBbox = await popoverContentHandle.boundingBox(); + expect(popoverBbox.width).not.toEqual(triggerBbox.width); + + screenshotCompares.push(await page.compareScreenshot()); + + for (const screenshotCompare of screenshotCompares) { + expect(screenshotCompare).toMatchScreenshot(); + } +}); + +test('should calculate popover width based on trigger width', async () => { + const page = await newE2EPage({ url: '/src/components/popover/test/size?ionic:_testing=true' }); + + const screenshotCompares = []; + + const trigger = await page.find('#cover-trigger'); + trigger.click(); + + await page.waitForSelector('.cover-popover'); + const popover = await page.find('.cover-popover'); + await popover.waitForVisible(); + + const triggerHandler = await page.$('#cover-trigger'); + const popoverContentHandle = await page.evaluateHandle(`document.querySelector('.cover-popover').shadowRoot.querySelector('.popover-content')`); + const triggerBbox = await triggerHandler.boundingBox(); + const popoverBbox = await popoverContentHandle.boundingBox(); + expect(popoverBbox.width).toEqual(triggerBbox.width); + + screenshotCompares.push(await page.compareScreenshot()); + + for (const screenshotCompare of screenshotCompares) { + expect(screenshotCompare).toMatchScreenshot(); + } +}); diff --git a/core/src/components/popover/test/size/index.html b/core/src/components/popover/test/size/index.html new file mode 100644 index 0000000000..de2c66b958 --- /dev/null +++ b/core/src/components/popover/test/size/index.html @@ -0,0 +1,73 @@ + + + + + Popover - Size + + + + + + + + + + + + Popover - Size + + + + +
+
+

Auto

+ Trigger + + + My really really really really long content + + +
+
+

Cover

+ Trigger + + + My really really really really long content + + +
+
+
+
+ + diff --git a/core/src/components/popover/test/trigger/e2e.ts b/core/src/components/popover/test/trigger/e2e.ts new file mode 100644 index 0000000000..1b22e14961 --- /dev/null +++ b/core/src/components/popover/test/trigger/e2e.ts @@ -0,0 +1,80 @@ +import { newE2EPage } from '@stencil/core/testing'; + +test('should open popover by left clicking on trigger', async () => { + const page = await newE2EPage({ url: '/src/components/popover/test/trigger?ionic:_testing=true' }); + + const screenshotCompares = []; + + await page.click('#left-click-trigger'); + await page.waitForSelector('.left-click-popover'); + + let popover = await page.find('.left-click-popover'); + await popover.waitForVisible(); + + screenshotCompares.push(await page.compareScreenshot()); + + for (const screenshotCompare of screenshotCompares) { + expect(screenshotCompare).toMatchScreenshot(); + } +}); + +test('should open popover by right clicking on trigger', async () => { + const page = await newE2EPage({ url: '/src/components/popover/test/trigger?ionic:_testing=true' }); + + const screenshotCompares = []; + + await page.click('#right-click-trigger', { button: 'right' }); + await page.waitForSelector('.right-click-popover'); + + let popover = await page.find('.right-click-popover'); + await popover.waitForVisible(); + + screenshotCompares.push(await page.compareScreenshot()); + + for (const screenshotCompare of screenshotCompares) { + expect(screenshotCompare).toMatchScreenshot(); + } +}); + +test('should open popover by hovering over trigger', async () => { + const page = await newE2EPage({ url: '/src/components/popover/test/trigger?ionic:_testing=true' }); + + const screenshotCompares = []; + + const button = await page.$('#hover-trigger'); + const bbox = await button.boundingBox(); + await page.mouse.move(bbox.x + 5, bbox.y + 5); + await page.waitForSelector('.hover-popover'); + + let popover = await page.find('.hover-popover'); + await popover.waitForVisible(); + + screenshotCompares.push(await page.compareScreenshot()); + + for (const screenshotCompare of screenshotCompares) { + expect(screenshotCompare).toMatchScreenshot(); + } +}); + +test('should not close main popover with dismiss-on-select when clicking a trigger', async () => { + const page = await newE2EPage({ url: '/src/components/popover/test/trigger?ionic:_testing=true' }); + + const screenshotCompares = []; + + await page.click('#nested-click-trigger'); + await page.waitForSelector('.nested-click-popover'); + + let firstPopover = await page.find('.nested-click-popover'); + await firstPopover.waitForVisible(); + + await page.click('#nested-click-trigger-two'); + + let secondPopover = await page.find('.nested-click-popover-two'); + await secondPopover.waitForVisible(); + + screenshotCompares.push(await page.compareScreenshot()); + + for (const screenshotCompare of screenshotCompares) { + expect(screenshotCompare).toMatchScreenshot(); + } +}); diff --git a/core/src/components/popover/test/trigger/index.html b/core/src/components/popover/test/trigger/index.html new file mode 100644 index 0000000000..068fb6a986 --- /dev/null +++ b/core/src/components/popover/test/trigger/index.html @@ -0,0 +1,114 @@ + + + + + Popover - Triggers + + + + + + + + + + + + Popover - Triggers + + + + +
+
+

Left Click

+ Trigger + + + Popover Content + + +
+
+

Right Click

+ Trigger + + + Popover Content + + +
+
+

Hover

+ Trigger + + + Popover Content + + +
+
+

Dismiss On Select

+ Trigger + + + Popover Content + + Trigger + + + Other Button + + + + Inner Popover Content + + + + +
+
+
+
+ + diff --git a/core/src/components/popover/test/util.spec.ts b/core/src/components/popover/test/util.spec.ts new file mode 100644 index 0000000000..6ba20ef6b1 --- /dev/null +++ b/core/src/components/popover/test/util.spec.ts @@ -0,0 +1,65 @@ +import { isTriggerElement, getIndexOfItem, getNextItem, getPrevItem } from '../utils'; + +describe('isTriggerElement', () => { + it('should return true is element is a trigger', () => { + const el = document.createElement('div'); + el.setAttribute('data-ion-popover-trigger', 'true'); + + expect(isTriggerElement(el)).toEqual(true); + }); + + it('should return false is element is not a trigger', () => { + const el = document.createElement('div'); + + expect(isTriggerElement(el)).toEqual(false); + }); +}); + +describe('getIndexOfItem', () => { + it('should return the correct index in an array of ion-items', () => { + const array = createArrayOfElements(['ion-item', 'ion-item', 'ion-item']); + + expect(getIndexOfItem(array, array[1])).toEqual(1); + }); + + it('should return -1 when ion-item not found', () => { + const el = document.createElement('ion-item'); + const array = createArrayOfElements(['ion-item', 'ion-item']); + + expect(getIndexOfItem(array, el)).toEqual(-1); + }); + + it('should return -1 if a non-ion-item is passed in', () => { + const array = createArrayOfElements(['ion-item', 'div', 'ion-item']); + + expect(getIndexOfItem(array, array[1])).toEqual(-1); + }); +}); + +describe('getNextItem', () => { + it('should get the next item in an array of ion-items', () => { + const array = createArrayOfElements(['ion-item', 'ion-item', 'ion-item']); + expect(getNextItem(array, array[1])).toEqual(array[2]); + }); + + it('should return undefined if there is no next item', () => { + const array = createArrayOfElements(['ion-item', 'ion-item', 'ion-item']); + expect(getNextItem(array, array[2])).toEqual(undefined); + }); +}); + +describe('getPrevItem', () => { + it('should get the previous item in an array of ion-items', () => { + const array = createArrayOfElements(['ion-item', 'ion-item', 'ion-item']); + expect(getPrevItem(array, array[1])).toEqual(array[0]); + }); + + it('should return undefined if there is no previous item', () => { + const array = createArrayOfElements(['ion-item', 'ion-item', 'ion-item']); + expect(getPrevItem(array, array[0])).toEqual(undefined); + }); +}); + +const createArrayOfElements = (tags: string[]) => { + return tags.map(tag => document.createElement(tag)); +} diff --git a/core/src/components/popover/utils.ts b/core/src/components/popover/utils.ts new file mode 100644 index 0000000000..1a9d4dcd23 --- /dev/null +++ b/core/src/components/popover/utils.ts @@ -0,0 +1,845 @@ +import { PopoverSize, PositionAlign, PositionReference, PositionSide, TriggerAction } from '../../interface'; +import { getElementRoot, raf } from '../../utils/helpers'; + +interface InteractionCallback { + eventName: string; + callback: (ev: any) => void; +} + +export interface ReferenceCoordinates { + top: number; + left: number; + width: number; + height: number; +} + +interface PopoverPosition { + top: number; + left: number; + referenceCoordinates?: ReferenceCoordinates; + arrowTop?: number; + arrowLeft?: number; + originX: string; + originY: string; +} + +export interface PopoverStyles { + top: number; + left: number; + bottom?: number; + originX: string; + originY: string; + checkSafeAreaLeft: boolean; + checkSafeAreaRight: boolean; + arrowTop: number; + arrowLeft: number; + addPopoverBottomClass: boolean; +} + +/** + * Returns the dimensions of the popover + * arrow on `ios` mode. If arrow is disabled + * returns (0, 0). + */ +export const getArrowDimensions = ( + arrowEl: HTMLElement | null +) => { + 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: PopoverSize, + contentEl: HTMLElement, + triggerEl?: HTMLElement +) => { + 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: HTMLElement, + triggerAction: TriggerAction, + popoverEl: HTMLIonPopoverElement, + parentPopoverEl: HTMLIonPopoverElement +) => { + let dismissCallbacks: InteractionCallback[] = []; + const root = getElementRoot(parentPopoverEl); + const parentContentEl = root.querySelector('.popover-content') as HTMLElement; + + 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: MouseEvent) => { + /** + * 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) as HTMLElement | null; + if (element === triggerEl) { return; } + + popoverEl.dismiss(undefined, undefined, false); + } + } + ]; + break; + case 'context-menu': + case 'click': + default: + dismissCallbacks = [ + { + eventName: 'click', + callback: (ev: MouseEvent) => { + /** + * Do not dismiss the popover is we + * are hovering over its trigger. + */ + const target = ev.target as HTMLElement; + 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: HTMLElement, + triggerAction: TriggerAction, + popoverEl: HTMLIonPopoverElement +) => { + let triggerCallbacks: InteractionCallback[] = []; + + /** + * Based upon the kind of trigger interaction + * the user wants, we setup the correct event + * listeners. + */ + switch (triggerAction) { + case 'hover': + let hoverTimeout: any; + + triggerCallbacks = [ + { + eventName: 'mouseenter', + callback: async (ev: Event) => { + 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: MouseEvent) => { + 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 as HTMLElement | null; + 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: Event) => ev.stopPropagation() + }, + { + eventName: 'ionPopoverActivateTrigger', + callback: (ev: Event) => popoverEl.presentFromTrigger(ev, true) + } + ] + + break; + case 'context-menu': + triggerCallbacks = [ + { + eventName: 'contextmenu', + callback: (ev: Event) => { + /** + * Prevents the platform context + * menu from appearing. + */ + ev.preventDefault(); + popoverEl.presentFromTrigger(ev); + } + }, + { + eventName: 'click', + callback: (ev: Event) => ev.stopPropagation() + }, + { + eventName: 'ionPopoverActivateTrigger', + callback: (ev: Event) => 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: Event) => popoverEl.presentFromTrigger(ev) + }, + { + eventName: 'ionPopoverActivateTrigger', + callback: (ev: Event) => 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: HTMLIonItemElement[], item: HTMLElement | null) => { + 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: HTMLIonItemElement[], currentItem: HTMLElement | null) => { + 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: HTMLIonItemElement[], currentItem: HTMLElement | null) => { + const currentItemIndex = getIndexOfItem(items, currentItem); + return items[currentItemIndex - 1]; +} + +/** + * Returns `true` if `el` has been designated + * as a trigger element for an ion-popover. + */ +export const isTriggerElement = (el: HTMLElement) => el.hasAttribute('data-ion-popover-trigger'); + +export const configureKeyboardInteraction = ( + popoverEl: HTMLIonPopoverElement +) => { + + const callback = async (ev: KeyboardEvent) => { + const activeElement = document.activeElement as HTMLElement | null; + let items = [] as any; + + /** + * 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 *)')); + /* tslint:disable-next-line */ + } catch {} + + 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': + const nextItem = getNextItem(items, activeElement); + // tslint:disable-next-line:strict-type-predicates + if (nextItem !== undefined) { + nextItem.focus(); + } + break; + /** + * ArrowUp should move focus to the previous focusable ion-item. + */ + case 'ArrowUp': + const prevItem = getPrevItem(items, activeElement); + // tslint:disable-next-line:strict-type-predicates + if (prevItem !== undefined) { + prevItem.focus(); + } + 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: boolean, + contentWidth: number, + contentHeight: number, + arrowWidth: number, + arrowHeight: number, + reference: PositionReference, + side: PositionSide, + align: PositionAlign, + defaultPosition: PopoverPosition, + triggerEl?: HTMLElement, + event?: MouseEvent, +): PopoverPosition => { + 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; + } + + referenceCoordinates = { + top: event.clientY, + left: event.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 actualTriggerEl = (triggerEl || event?.target) as HTMLElement | null; + 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: PositionSide, + align: PositionAlign, + isRTL: boolean +) => { + 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: PositionAlign) => { + switch (align) { + case 'start': + return 'left'; + case 'center': + return 'center'; + case 'end': + return 'right'; + } +} + +const getOriginYAlignment = (align: PositionAlign) => { + 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: PositionSide, + arrowWidth: number, + arrowHeight: number, + top: number, + left: number, + contentWidth: number, + contentHeight: number, + isRTL: boolean +) => { + /** + * 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: PositionSide, + triggerBoundingBox: ReferenceCoordinates, + contentWidth: number, + contentHeight: number, + arrowWidth: number, + arrowHeight: number, + isRTL: boolean +) => { + 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: PositionAlign, + side: PositionSide, + triggerBoundingBox: ReferenceCoordinates, + contentWidth: number, + contentHeight: number +) => { + 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: PositionSide, + triggerBoundingBox: ReferenceCoordinates, + contentWidth: number, + contentHeight: number +) => { + 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: PositionSide, + triggerBoundingBox: ReferenceCoordinates, + contentWidth: number, + contentHeight: number +) => { + 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: PositionSide, + coordTop: number, + coordLeft: number, + bodyPadding: number, + bodyWidth: number, + bodyHeight: number, + contentWidth: number, + contentHeight: number, + safeAreaMargin: number, + contentOriginX: string, + contentOriginY: string, + triggerCoordinates?: ReferenceCoordinates, + coordArrowTop = 0, + coordArrowLeft = 0, + arrowHeight = 0 +): PopoverStyles => { + 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) { + top = 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: PositionSide, + didAdjustBounds = false, + ev?: Event, + trigger?: HTMLElement +) => { + /** + * 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; +} diff --git a/core/src/utils/overlays.ts b/core/src/utils/overlays.ts index 54c4ea0e47..bf8cadd41b 100644 --- a/core/src/utils/overlays.ts +++ b/core/src/utils/overlays.ts @@ -71,7 +71,7 @@ export const createOverlay = (tagName: string, const focusableQueryString = '[tabindex]:not([tabindex^="-"]), input:not([type=hidden]):not([tabindex^="-"]), textarea:not([tabindex^="-"]), button:not([tabindex^="-"]), select:not([tabindex^="-"]), .ion-focusable:not([tabindex^="-"])'; const innerFocusableQueryString = 'input:not([type=hidden]), textarea, button, select'; -const focusFirstDescendant = (ref: Element, overlay: HTMLIonOverlayElement) => { +export const focusFirstDescendant = (ref: Element, overlay: HTMLIonOverlayElement) => { let firstInput = ref.querySelector(focusableQueryString) as HTMLElement | null; const shadowRoot = firstInput && firstInput.shadowRoot;