/
githubmirror
/
ionic-framework
Обзор
Документация
Войти
/
githubmirror
/
ionic-framework
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
core/src/components/popover/popover.tsx
804 строки
25 KB
Brandy Smith
fix(popover): recalculate the content dimensions after the header has fully loaded (#30853)
10 дек 2025, 23:56
Не верифицирован
10 дек 2025, 23:56
99dcf38
Код
Авторство
О чём код?
import type { ComponentInterface, EventEmitter } from '@stencil/core'; import { Component, Element, Event, Host, Method, Prop, State, Watch, h } from '@stencil/core'; import { focusFirstDescendant } from '@utils/focus-trap'; import { CoreDelegate, attachComponent, detachComponent } from '@utils/framework-delegate'; import { addEventListener, raf, hasLazyBuild } from '@utils/helpers'; import { createLockController } from '@utils/lock-controller'; import { printIonWarning } from '@utils/logging'; import { BACKDROP, dismiss, eventMethod, prepareOverlay, present, setOverlayId, FOCUS_TRAP_DISABLE_CLASS, } from '@utils/overlays'; import { isPlatform } from '@utils/platform'; import { getClassMap } from '@utils/theme'; import { deepReady, waitForMount } from '@utils/transition'; import { getIonMode } from '../../global/ionic-global'; import type { AnimationBuilder, ComponentProps, ComponentRef, FrameworkDelegate } from '../../interface'; import type { OverlayEventDetail } from '../../utils/overlays-interface'; 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 type { PopoverInterface, PopoverSize, PositionAlign, PositionReference, PositionSide, TriggerAction, } from './popover-interface'; import { configureDismissInteraction, configureKeyboardInteraction, configureTriggerInteraction } from './utils'; // TODO(FW-2832): types /** * @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use. * * @slot - Content is placed inside of the `.popover-content` element. * * @part backdrop - The `ion-backdrop` element. * @part arrow - The arrow that points to the reference element. Only applies on `ios` mode. * @part content - The wrapper element for the default slot. */ @Component({ tag: 'ion-popover', styleUrls: { ios: 'popover.ios.scss', md: 'popover.md.scss', }, shadow: true, }) export class Popover implements ComponentInterface, PopoverInterface { private usersElement?: HTMLElement; private triggerEl?: HTMLElement | null; private parentPopover: HTMLIonPopoverElement | null = null; private coreDelegate: FrameworkDelegate = CoreDelegate(); private readonly lockController = createLockController(); private destroyTriggerInteraction?: () => void; private destroyKeyboardInteraction?: () => void; private destroyDismissInteraction?: () => void; private headerResizeObserver?: ResizeObserver; private inline = false; private workingDelegate?: FrameworkDelegate; private focusDescendantOnPresent = false; lastFocus?: HTMLElement; @State() presented = false; @Element() el!: HTMLIonPopoverElement; /** @internal */ @Prop() hasController = false; /** @internal */ @Prop() delegate?: FrameworkDelegate; /** @internal */ @Prop() overlayIndex!: number; /** * Animation to use when the popover is presented. */ @Prop() enterAnimation?: AnimationBuilder; /** * Animation to use when the popover is dismissed. */ @Prop() leaveAnimation?: AnimationBuilder; /** * 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`. */ @Prop() component?: ComponentRef; /** * 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. */ @Prop() componentProps?: ComponentProps; /** * If `true`, the keyboard will be automatically dismissed when the overlay is presented. */ @Prop() keyboardClose = true; /** * Additional classes to apply for custom CSS. If multiple classes are * provided they should be separated by spaces. * @internal */ @Prop() cssClass?: string | string[]; /** * If `true`, the popover will be dismissed when the backdrop is clicked. */ @Prop() backdropDismiss = true; /** * The event to pass to the popover animation. */ @Prop() event: any; /** * If `true`, a backdrop will be displayed behind the popover. * This property controls whether or not the backdrop * darkens the screen when the popover is presented. * It does not control whether or not the backdrop * is active or present in the DOM. */ @Prop() showBackdrop = true; /** * 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). */ @Prop() translucent = false; /** * If `true`, the popover will animate. */ @Prop() animated = true; /** * Additional attributes to pass to the popover. */ @Prop() htmlAttributes?: { [key: string]: any }; /** * 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 set to a static default value. */ @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. * Defaults to `"center"` for `ios` mode, and `"start"` for `md` mode. */ @Prop({ mutable: true }) alignment?: PositionAlign; /** * If `true`, the popover will display an arrow that points at the * `reference` when running in `ios` mode. Does not apply in `md` mode. */ @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 * 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. */ @Prop() isOpen = false; /** * @internal * * If `true` the popover will not register its own keyboard event handlers. * This allows the contents of the popover to handle their own keyboard interactions. * * If `false`, the popover will register its own keyboard event handlers for * navigating `ion-list` items within a popover (up/down/home/end/etc.). * This will also cancel browser keyboard event bindings to prevent scroll * behavior in a popover using a list of items. */ @Prop() keyboardEvents = false; /** * If `true`, focus will not be allowed to move outside of this overlay. * If `false`, focus will be allowed to move outside of the overlay. * * In most scenarios this property should remain set to `true`. Setting * this property to `false` can cause severe accessibility issues as users * relying on assistive technologies may be able to move focus into * a confusing state. We recommend only setting this to `false` when * absolutely necessary. * * Developers may want to consider disabling focus trapping if this * overlay presents a non-Ionic overlay from a 3rd party library. * Developers would disable focus trapping on the Ionic overlay * when presenting the 3rd party overlay and then re-enable * focus trapping when dismissing the 3rd party overlay and moving * focus back to the Ionic overlay. */ @Prop() focusTrap = true; @Watch('trigger') @Watch('triggerAction') onTriggerChange() { this.configureTriggerInteraction(); } @Watch('isOpen') onIsOpenChange(newValue: boolean, oldValue: boolean) { if (newValue === true && oldValue === false) { this.present(); } else if (newValue === false && oldValue === true) { this.dismiss(); } } /** * If `true`, the component passed into `ion-popover` will * automatically be mounted when the popover is created. The * component will remain mounted even when the popover is dismissed. * However, the component will be destroyed when the popover is * destroyed. This property is not reactive and should only be * used when initially creating a popover. * * Note: This feature only applies to inline popovers in JavaScript * frameworks such as Angular, React, and Vue. */ @Prop() keepContentsMounted = false; /** * Emitted after the popover has presented. */ @Event({ eventName: 'ionPopoverDidPresent' }) didPresent!: EventEmitter<void>; /** * Emitted before the popover has presented. */ @Event({ eventName: 'ionPopoverWillPresent' }) willPresent!: EventEmitter<void>; /** * Emitted before the popover has dismissed. */ @Event({ eventName: 'ionPopoverWillDismiss' }) willDismiss!: EventEmitter<OverlayEventDetail>; /** * Emitted after the popover has dismissed. */ @Event({ eventName: 'ionPopoverDidDismiss' }) didDismiss!: EventEmitter<OverlayEventDetail>; /** * Emitted after the popover has presented. * Shorthand for ionPopoverWillDismiss. */ @Event({ eventName: 'didPresent' }) didPresentShorthand!: EventEmitter<void>; /** * Emitted before the popover has presented. * Shorthand for ionPopoverWillPresent. */ @Event({ eventName: 'willPresent' }) willPresentShorthand!: EventEmitter<void>; /** * Emitted before the popover has dismissed. * Shorthand for ionPopoverWillDismiss. */ @Event({ eventName: 'willDismiss' }) willDismissShorthand!: EventEmitter<OverlayEventDetail>; /** * Emitted after the popover has dismissed. * Shorthand for ionPopoverDidDismiss. */ @Event({ eventName: 'didDismiss' }) didDismissShorthand!: EventEmitter<OverlayEventDetail>; /** * Emitted before the popover has presented, but after the component * has been mounted in the DOM. * This event exists for ion-popover to resolve an issue with the * popover and the lazy build, that the transition is unable to get * the correct dimensions of the popover with auto sizing. * This is not required for other overlays, since the existing * overlay transitions are not effected by auto sizing content. * * @internal */ @Event() ionMount!: EventEmitter<void>; connectedCallback() { const { configureTriggerInteraction, el } = this; prepareOverlay(el); configureTriggerInteraction(); } disconnectedCallback() { const { destroyTriggerInteraction } = this; if (destroyTriggerInteraction) { destroyTriggerInteraction(); } if (this.headerResizeObserver) { this.headerResizeObserver.disconnect(); this.headerResizeObserver = undefined; } } componentWillLoad() { const { el } = this; const popoverId = this.htmlAttributes?.id ?? setOverlayId(el); this.parentPopover = el.closest(`ion-popover:not(#${popoverId})`) as HTMLIonPopoverElement | null; if (this.alignment === undefined) { this.alignment = getIonMode(this) === 'ios' ? 'center' : 'start'; } } componentDidLoad() { const { parentPopover, isOpen } = this; /** * If popover was rendered with isOpen="true" * then we should open popover immediately. */ if (isOpen === true) { raf(() => this.present()); } if (parentPopover) { addEventListener(parentPopover, 'ionPopoverWillDismiss', () => { this.dismiss(undefined, undefined, false); }); } /** * When binding values in frameworks such as Angular * it is possible for the value to be set after the Web Component * initializes but before the value watcher is set up in Stencil. * As a result, the watcher callback may not be fired. * We work around this by manually calling the watcher * callback when the component has loaded and the watcher * is configured. */ 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.focusDescendantOnPresent = focusDescendant; await this.present(event); this.focusDescendantOnPresent = false; } /** * Determines whether or not an overlay * is being used inline or via a controller/JS * and returns the correct delegate. * By default, subsequent calls to getDelegate * will use a cached version of the delegate. * This is useful for calling dismiss after * present so that the correct delegate is given. */ private getDelegate(force = false) { if (this.workingDelegate && !force) { return { delegate: this.workingDelegate, inline: this.inline, }; } /** * If using overlay inline * we potentially need to use the coreDelegate * so that this works in vanilla JS apps. * If a developer has presented this component * via a controller, then we can assume * the component is already in the * correct place. */ const parentEl = this.el.parentNode as HTMLElement | null; const inline = (this.inline = parentEl !== null && !this.hasController); const delegate = (this.workingDelegate = inline ? this.delegate || this.coreDelegate : this.delegate); return { inline, delegate }; } /** * Present the popover overlay after it has been created. * Developers can pass a mouse, touch, or pointer event * to position the popover relative to where that event * was dispatched. * * @param event The event to position the popover relative to. */ @Method() async present(event?: MouseEvent | TouchEvent | PointerEvent | CustomEvent): Promise<void> { const unlock = await this.lockController.lock(); if (this.presented) { unlock(); return; } const { el } = this; const { inline, delegate } = this.getDelegate(true); /** * Emit ionMount so JS Frameworks have an opportunity * to add the child component to the DOM. The child * component will be assigned to this.usersElement below. */ this.ionMount.emit(); this.usersElement = await attachComponent( delegate, el, this.component, ['popover-viewport'], this.componentProps, inline ); this.recalculateContentOnHeaderReady(); if (!this.keyboardEvents) { this.configureKeyboardInteraction(); } this.configureDismissInteraction(); /** * When using the lazy loaded build of Stencil, we need to wait * for every Stencil component instance to be ready before presenting * otherwise there can be a flash of unstyled content. With the * custom elements bundle we need to wait for the JS framework * mount the inner contents of the overlay otherwise WebKit may * get the transition incorrect. */ if (hasLazyBuild(el)) { await deepReady(this.usersElement); /** * If keepContentsMounted="true" then the * JS Framework has already mounted the inner * contents so there is no need to wait. * Otherwise, we need to wait for the JS * Framework to mount the inner contents * of this component. */ } else if (!this.keepContentsMounted) { await waitForMount(); } await present<PopoverPresentOptions>(this, 'popoverEnter', iosEnterAnimation, mdEnterAnimation, { event: event || this.event, size: this.size, trigger: this.triggerEl, reference: this.reference, side: this.side, align: this.alignment, }); /** * 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(el); } unlock(); } /** * Watch the header for height changes and trigger content dimension * recalculation when the header has a height > 0. This sets the offset-top * of the content to the height of the header correctly. */ private recalculateContentOnHeaderReady() { const popoverContent = this.el.shadowRoot?.querySelector('.popover-content'); if (!popoverContent) { return; } const contentContainer = this.usersElement || popoverContent; const header = contentContainer.querySelector('ion-header') as HTMLElement | null; const contentElements = contentContainer.querySelectorAll('ion-content'); if (!header || contentElements.length === 0) { return; } this.headerResizeObserver = new ResizeObserver(async () => { if (header.offsetHeight > 0) { this.headerResizeObserver?.disconnect(); this.headerResizeObserver = undefined; for (const contentEl of contentElements) { await contentEl.recalculateDimensions(); } } }); this.headerResizeObserver.observe(header); } /** * Dismiss the popover overlay after it has been presented. * This is a no-op if the overlay has not been presented yet. If you want * to remove an overlay from the DOM that was never presented, use the * [remove](https://developer.mozilla.org/en-US/docs/Web/API/Element/remove) method. * * @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, dismissParentPopover = true): Promise<boolean> { const unlock = await this.lockController.lock(); const { destroyKeyboardInteraction, destroyDismissInteraction } = this; if (dismissParentPopover && this.parentPopover) { this.parentPopover.dismiss(data, role, dismissParentPopover); } const shouldDismiss = await dismiss<PopoverDismissOptions>( this, data, role, 'popoverLeave', iosLeaveAnimation, mdLeaveAnimation, this.event ); if (shouldDismiss) { if (destroyKeyboardInteraction) { destroyKeyboardInteraction(); this.destroyKeyboardInteraction = undefined; } if (destroyDismissInteraction) { destroyDismissInteraction(); this.destroyDismissInteraction = undefined; } /** * If using popover inline * we potentially need to use the coreDelegate * so that this works in vanilla JS apps */ const { delegate } = this.getDelegate(); await detachComponent(delegate, this.usersElement); } unlock(); return shouldDismiss; } /** * @internal */ @Method() async getParentPopover(): Promise<HTMLIonPopoverElement | null> { return this.parentPopover; } /** * Returns a promise that resolves when the popover did dismiss. */ @Method() onDidDismiss<T = any>(): Promise<OverlayEventDetail<T>> { return eventMethod(this.el, 'ionPopoverDidDismiss'); } /** * Returns a promise that resolves when the popover will dismiss. */ @Method() onWillDismiss<T = any>(): Promise<OverlayEventDetail<T>> { return eventMethod(this.el, 'ionPopoverWillDismiss'); } private onBackdropTap = () => { this.dismiss(undefined, BACKDROP); }; private onLifecycle = (modalEvent: CustomEvent) => { const el = this.usersElement; const name = LIFECYCLE_MAP[modalEvent.type]; if (el && name) { const event = new CustomEvent(name, { bubbles: false, cancelable: false, detail: modalEvent.detail, }); el.dispatchEvent(event); } }; private configureTriggerInteraction = () => { const { trigger, triggerAction, el, destroyTriggerInteraction } = this; if (destroyTriggerInteraction) { destroyTriggerInteraction(); } if (trigger === undefined) { return; } const triggerEl = (this.triggerEl = trigger !== undefined ? document.getElementById(trigger) : null); if (!triggerEl) { printIonWarning( `[ion-popover] - A trigger element with the ID "${trigger}" was not found in the DOM. The trigger element must be in the DOM when the "trigger" property is set on ion-popover.`, this.el ); 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, parentPopover, dismissOnSelect, side, arrow, htmlAttributes, focusTrap } = this; const desktop = isPlatform('desktop'); const enableArrow = arrow && !parentPopover; return ( <Host aria-modal="true" no-router tabindex="-1" {...(htmlAttributes as any)} style={{ zIndex: `${20000 + this.overlayIndex}`, }} class={{ ...getClassMap(this.cssClass), [mode]: true, 'popover-translucent': this.translucent, 'overlay-hidden': true, 'popover-desktop': desktop, [`popover-side-${side}`]: true, [FOCUS_TRAP_DISABLE_CLASS]: focusTrap === false, 'popover-nested': !!parentPopover, }} onIonPopoverDidPresent={onLifecycle} onIonPopoverWillPresent={onLifecycle} onIonPopoverWillDismiss={onLifecycle} onIonPopoverDidDismiss={onLifecycle} onIonBackdropTap={this.onBackdropTap} > {!parentPopover && <ion-backdrop tappable={this.backdropDismiss} visible={this.showBackdrop} part="backdrop" />} <div class="popover-wrapper ion-overlay-wrapper" onClick={dismissOnSelect ? () => this.dismiss() : undefined}> {enableArrow && <div class="popover-arrow" part="arrow"></div>} <div class="popover-content" part="content"> <slot></slot> </div> </div> </Host> ); } } const LIFECYCLE_MAP: any = { ionPopoverDidPresent: 'ionViewDidEnter', ionPopoverWillPresent: 'ionViewWillEnter', ionPopoverWillDismiss: 'ionViewWillLeave', ionPopoverDidDismiss: 'ionViewDidLeave', }; interface PopoverPresentOptions { /** * The original target event that presented the popover. */ event: Event; /** * Describes how to calculate the popover width. */ size: PopoverSize; /** * The element that causes the popover to open. */ trigger?: HTMLElement | null; /** * Describes what to position the popover relative to. */ reference: PositionReference; /** * Side of the `reference` point to position the popover on. */ side: PositionSide; /** * Describes how to align the popover content with the `reference` point. */ align?: PositionAlign; } type PopoverDismissOptions = Event;