diff --git a/core/api.txt b/core/api.txt index b4036e6b8e8..5f846b29793 100644 --- a/core/api.txt +++ b/core/api.txt @@ -1493,6 +1493,8 @@ ion-refresher,method,cancel,cancel() => Promise ion-refresher,method,complete,complete() => Promise ion-refresher,method,getProgress,getProgress() => Promise ion-refresher,event,ionPull,void,true +ion-refresher,event,ionPullEnd,RefresherPullEndEventDetail,true +ion-refresher,event,ionPullStart,void,true ion-refresher,event,ionRefresh,RefresherEventDetail,true ion-refresher,event,ionStart,void,true diff --git a/core/src/components.d.ts b/core/src/components.d.ts index ae6d9f4c2fb..8e422764102 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -29,7 +29,7 @@ import { PickerButton, PickerColumn } from "./components/picker-legacy/picker-in import { PopoverSize, PositionAlign, PositionReference, PositionSide, TriggerAction } from "./components/popover/popover-interface"; import { RadioGroupChangeEventDetail, RadioGroupCompareFn } from "./components/radio-group/radio-group-interface"; import { PinFormatter, RangeChangeEventDetail, RangeKnobMoveEndEventDetail, RangeKnobMoveStartEventDetail, RangeValue } from "./components/range/range-interface"; -import { RefresherEventDetail } from "./components/refresher/refresher-interface"; +import { RefresherEventDetail, RefresherPullEndEventDetail } from "./components/refresher/refresher-interface"; import { ItemReorderEventDetail, ReorderEndEventDetail, ReorderMoveEventDetail } from "./components/reorder-group/reorder-group-interface"; import { NavigationHookCallback } from "./components/route/route-interface"; import { SearchbarChangeEventDetail, SearchbarInputEventDetail } from "./components/searchbar/searchbar-interface"; @@ -67,7 +67,7 @@ export { PickerButton, PickerColumn } from "./components/picker-legacy/picker-in export { PopoverSize, PositionAlign, PositionReference, PositionSide, TriggerAction } from "./components/popover/popover-interface"; export { RadioGroupChangeEventDetail, RadioGroupCompareFn } from "./components/radio-group/radio-group-interface"; export { PinFormatter, RangeChangeEventDetail, RangeKnobMoveEndEventDetail, RangeKnobMoveStartEventDetail, RangeValue } from "./components/range/range-interface"; -export { RefresherEventDetail } from "./components/refresher/refresher-interface"; +export { RefresherEventDetail, RefresherPullEndEventDetail } from "./components/refresher/refresher-interface"; export { ItemReorderEventDetail, ReorderEndEventDetail, ReorderMoveEventDetail } from "./components/reorder-group/reorder-group-interface"; export { NavigationHookCallback } from "./components/route/route-interface"; export { SearchbarChangeEventDetail, SearchbarInputEventDetail } from "./components/searchbar/searchbar-interface"; @@ -2745,7 +2745,7 @@ export namespace Components { */ "mode"?: "ios" | "md"; /** - * How much to multiply the pull speed by. To slow the pull animation down, pass a number less than `1`. To speed up the pull, pass a number greater than `1`. The default value is `1` which is equal to the speed of the cursor. If a negative value is passed in, the factor will be `1` instead. For example: If the value passed is `1.2` and the content is dragged by `10` pixels, instead of `10` pixels the content will be pulled by `12` pixels (an increase of 20 percent). If the value passed is `0.8`, the dragged amount will be `8` pixels, less than the amount the cursor has moved. Does not apply when the refresher content uses a spinner, enabling the native refresher. + * How much to multiply the pull speed by. To slow the pull animation down, pass a number less than `1`. To speed up the pull, pass a number greater than `1`. The default value is `1` which is equal to the speed of the cursor. If a negative value is passed in, the factor will be `1` instead. For example, If the value passed is `1.2` and the content is dragged by `10` pixels, instead of `10` pixels, the content will be pulled by `12` pixels (an increase of 20 percent). If the value passed is `0.8`, the dragged amount will be `8` pixels, less than the amount the cursor has moved. Does not apply when the refresher content uses a spinner, enabling the native refresher. * @default 1 */ "pullFactor": number; @@ -4749,6 +4749,8 @@ declare global { "ionRefresh": RefresherEventDetail; "ionPull": void; "ionStart": void; + "ionPullStart": void; + "ionPullEnd": RefresherPullEndEventDetail; } interface HTMLIonRefresherElement extends Components.IonRefresher, HTMLStencilElement { addEventListener(type: K, listener: (this: HTMLIonRefresherElement, ev: IonRefresherCustomEvent) => any, options?: boolean | AddEventListenerOptions): void; @@ -8009,16 +8011,25 @@ declare namespace LocalJSX { * Emitted while the user is pulling down the content and exposing the refresher. */ "onIonPull"?: (event: IonRefresherCustomEvent) => void; + /** + * Emitted when the refresher has returned to the inactive state after a pull gesture. This fires whether the refresh completed successfully or was canceled. + */ + "onIonPullEnd"?: (event: IonRefresherCustomEvent) => void; + /** + * Emitted when the user begins to start pulling down. + */ + "onIonPullStart"?: (event: IonRefresherCustomEvent) => void; /** * Emitted when the user lets go of the content and has pulled down further than the `pullMin` or pulls the content down and exceeds the pullMax. Updates the refresher state to `refreshing`. The `complete()` method should be called when the async operation has completed. */ "onIonRefresh"?: (event: IonRefresherCustomEvent) => void; /** - * Emitted when the user begins to start pulling down. + * Emitted when the user begins to start pulling down. TODO(FW-7044): Remove this in a major release + * @deprecated Use `ionPullStart` instead. */ "onIonStart"?: (event: IonRefresherCustomEvent) => void; /** - * How much to multiply the pull speed by. To slow the pull animation down, pass a number less than `1`. To speed up the pull, pass a number greater than `1`. The default value is `1` which is equal to the speed of the cursor. If a negative value is passed in, the factor will be `1` instead. For example: If the value passed is `1.2` and the content is dragged by `10` pixels, instead of `10` pixels the content will be pulled by `12` pixels (an increase of 20 percent). If the value passed is `0.8`, the dragged amount will be `8` pixels, less than the amount the cursor has moved. Does not apply when the refresher content uses a spinner, enabling the native refresher. + * How much to multiply the pull speed by. To slow the pull animation down, pass a number less than `1`. To speed up the pull, pass a number greater than `1`. The default value is `1` which is equal to the speed of the cursor. If a negative value is passed in, the factor will be `1` instead. For example, If the value passed is `1.2` and the content is dragged by `10` pixels, instead of `10` pixels, the content will be pulled by `12` pixels (an increase of 20 percent). If the value passed is `0.8`, the dragged amount will be `8` pixels, less than the amount the cursor has moved. Does not apply when the refresher content uses a spinner, enabling the native refresher. * @default 1 */ "pullFactor"?: number; diff --git a/core/src/components/refresher/refresher-interface.ts b/core/src/components/refresher/refresher-interface.ts index 20fd97e1823..9866a4cc86f 100644 --- a/core/src/components/refresher/refresher-interface.ts +++ b/core/src/components/refresher/refresher-interface.ts @@ -2,7 +2,16 @@ export interface RefresherEventDetail { complete(): void; } +export interface RefresherPullEndEventDetail { + reason: 'complete' | 'cancel'; +} + export interface RefresherCustomEvent extends CustomEvent { detail: RefresherEventDetail; target: HTMLIonRefresherElement; } + +export interface RefresherPullEndCustomEvent extends CustomEvent { + detail: RefresherPullEndEventDetail; + target: HTMLIonRefresherElement; +} diff --git a/core/src/components/refresher/refresher.tsx b/core/src/components/refresher/refresher.tsx index 77f72f1a68b..2b933bc5f77 100644 --- a/core/src/components/refresher/refresher.tsx +++ b/core/src/components/refresher/refresher.tsx @@ -14,7 +14,7 @@ import { ImpactStyle, hapticImpact } from '@utils/native/haptic'; import { getIonMode } from '../../global/ionic-global'; import type { Animation, Gesture, GestureDetail } from '../../interface'; -import type { RefresherEventDetail } from './refresher-interface'; +import type { RefresherEventDetail, RefresherPullEndEventDetail } from './refresher-interface'; import { createPullingAnimation, createSnapBackAnimation, @@ -107,8 +107,8 @@ export class Refresher implements ComponentInterface { * than `1`. The default value is `1` which is equal to the speed of the cursor. * If a negative value is passed in, the factor will be `1` instead. * - * For example: If the value passed is `1.2` and the content is dragged by - * `10` pixels, instead of `10` pixels the content will be pulled by `12` pixels + * For example, If the value passed is `1.2` and the content is dragged by + * `10` pixels, instead of `10` pixels, the content will be pulled by `12` pixels * (an increase of 20 percent). If the value passed is `0.8`, the dragged amount * will be `8` pixels, less than the amount the cursor has moved. * @@ -143,9 +143,24 @@ export class Refresher implements ComponentInterface { /** * Emitted when the user begins to start pulling down. + * TODO(FW-7044): Remove this in a major release + * + * @deprecated Use `ionPullStart` instead. */ @Event() ionStart!: EventEmitter; + /** + * Emitted when the user begins to start pulling down. + */ + @Event() ionPullStart!: EventEmitter; + + /** + * Emitted when the refresher has returned to the inactive state + * after a pull gesture. This fires whether the refresh completed + * successfully or was canceled. + */ + @Event() ionPullEnd!: EventEmitter; + private async checkNativeRefresher() { const useNativeRefresher = await shouldUseNativeRefresher(this.el, getIonMode(this)); if (useNativeRefresher && !this.nativeRefresher) { @@ -182,6 +197,10 @@ export class Refresher implements ComponentInterface { this.progress = 0; this.state = RefresherState.Inactive; + + this.ionPullEnd.emit({ + reason: state === RefresherState.Completing ? 'complete' : 'cancel', + }); } private async setupiOSNativeRefresher( @@ -224,6 +243,7 @@ export class Refresher implements ComponentInterface { if (!this.didStart) { this.didStart = true; this.ionStart.emit(); + this.ionPullStart.emit(); } // emit "pulling" on every move @@ -308,6 +328,7 @@ export class Refresher implements ComponentInterface { this.lastVelocityY = ev.velocityY; }, onEnd: () => { + const hadStarted = this.didStart; this.pointerDown = false; this.didStart = false; @@ -316,6 +337,12 @@ export class Refresher implements ComponentInterface { this.needsCompletion = false; } else if (this.didRefresh) { readTask(() => translateElement(this.elementToTransform, `${this.el.clientHeight}px`)); + } else if (hadStarted) { + /** + * User started pulling but released before reaching the refresh threshold. + * Emit ionPullEnd to complete the event pair. + */ + this.ionPullEnd.emit({ reason: 'cancel' }); } }, }); @@ -378,6 +405,7 @@ export class Refresher implements ComponentInterface { ev.data.animation = animation; animation.progressStart(false, 0); this.ionStart.emit(); + this.ionPullStart.emit(); this.animations.push(animation); return; @@ -405,6 +433,7 @@ export class Refresher implements ComponentInterface { this.animations = []; this.gesture!.enable(true); this.state = RefresherState.Inactive; + this.ionPullEnd.emit({ reason: 'cancel' }); }); return; } @@ -684,6 +713,7 @@ export class Refresher implements ComponentInterface { if (!this.didStart) { this.didStart = true; this.ionStart.emit(); + this.ionPullStart.emit(); } // emit "pulling" on every move @@ -731,6 +761,16 @@ export class Refresher implements ComponentInterface { * available right away. */ this.restoreOverflowStyle(); + + /** + * If ionPullStart was emitted, we need to emit ionPullEnd + * even though the gesture was aborted before reaching the + * pulling threshold. + */ + if (this.didStart) { + this.didStart = false; + this.ionPullEnd.emit({ reason: 'cancel' }); + } } } @@ -783,6 +823,10 @@ export class Refresher implements ComponentInterface { if (this.contentFullscreen && this.backgroundContentEl) { this.backgroundContentEl?.style.removeProperty('--offset-top'); } + + this.ionPullEnd.emit({ + reason: state === RefresherState.Completing ? 'complete' : 'cancel', + }); }, 600); // reset the styles on the scroll element diff --git a/core/src/components/refresher/test/basic/index.html b/core/src/components/refresher/test/basic/index.html index 60982df8670..fdc65b20983 100644 --- a/core/src/components/refresher/test/basic/index.html +++ b/core/src/components/refresher/test/basic/index.html @@ -56,6 +56,17 @@ window.dispatchEvent(new CustomEvent('ionRefreshComplete')); }); + // Event listeners for new ionPullStart and ionPullEnd events + refresher.addEventListener('ionPullStart', function () { + console.log('ionPullStart fired'); + window.dispatchEvent(new CustomEvent('ionPullStartFired')); + }); + + refresher.addEventListener('ionPullEnd', function (event) { + console.log('ionPullEnd fired', event.detail); + window.dispatchEvent(new CustomEvent('ionPullEndFired', { detail: event.detail })); + }); + function render() { let html = ''; for (let item of items) { diff --git a/core/src/components/refresher/test/basic/refresher.e2e.ts b/core/src/components/refresher/test/basic/refresher.e2e.ts index 70f38bc88e7..4cdeeb872df 100644 --- a/core/src/components/refresher/test/basic/refresher.e2e.ts +++ b/core/src/components/refresher/test/basic/refresher.e2e.ts @@ -1,5 +1,5 @@ import { expect } from '@playwright/test'; -import { configs, test } from '@utils/test/playwright'; +import { configs, dragElementByYAxis, test } from '@utils/test/playwright'; import { pullToRefresh } from '../test.utils'; @@ -22,6 +22,41 @@ configs({ directions: ['ltr'] }).forEach(({ title, config }) => { expect(await items.count()).toBe(60); }); + + test('should emit ionPullStart and ionPullEnd with reason complete', async ({ page }) => { + await page.locator('ion-refresher.hydrated').waitFor({ state: 'attached' }); + + const ionPullStartEvent = await page.spyOnEvent('ionPullStartFired'); + const ionPullEndEvent = await page.spyOnEvent('ionPullEndFired'); + + await pullToRefresh(page); + + // Wait for the close animation timeout (600ms) to complete + await page.waitForTimeout(700); + + expect(ionPullStartEvent).toHaveReceivedEventTimes(1); + expect(ionPullEndEvent).toHaveReceivedEventTimes(1); + expect(ionPullEndEvent).toHaveReceivedEventDetail({ reason: 'complete' }); + }); + + test('should emit ionPullEnd with reason cancel when pull is released early', async ({ page }) => { + const target = page.locator('body'); + + await page.locator('ion-refresher.hydrated').waitFor({ state: 'attached' }); + + const ionPullStartEvent = await page.spyOnEvent('ionPullStartFired'); + const ionPullEndEvent = await page.spyOnEvent('ionPullEndFired'); + + // Pull down only 40px (less than pullMin of 60px) to trigger cancel + await dragElementByYAxis(target, page, 40); + + // Wait for the cancel animation to complete + await page.waitForTimeout(700); + + expect(ionPullStartEvent).toHaveReceivedEventTimes(1); + expect(ionPullEndEvent).toHaveReceivedEventTimes(1); + expect(ionPullEndEvent).toHaveReceivedEventDetail({ reason: 'cancel' }); + }); }); test.describe('native refresher', () => { @@ -41,6 +76,30 @@ configs({ directions: ['ltr'] }).forEach(({ title, config }) => { expect(await items.count()).toBe(60); }); + + test('should emit ionPullStart and ionPullEnd with reason complete', async ({ page }) => { + const refresherContent = page.locator('ion-refresher-content'); + refresherContent.evaluateHandle((el: any) => { + // Resets the pullingIcon to enable the native refresher + el.pullingIcon = undefined; + }); + + await page.waitForChanges(); + + await page.locator('ion-refresher.hydrated').waitFor({ state: 'attached' }); + + const ionPullStartEvent = await page.spyOnEvent('ionPullStartFired'); + const ionPullEndEvent = await page.spyOnEvent('ionPullEndFired'); + + await pullToRefresh(page); + + // Wait for the reset animation to complete (native refresher takes longer due to CSS transitions) + await page.waitForTimeout(1500); + + expect(ionPullStartEvent).toHaveReceivedEventTimes(1); + expect(ionPullEndEvent).toHaveReceivedEventTimes(1); + expect(ionPullEndEvent).toHaveReceivedEventDetail({ reason: 'complete' }); + }); }); }); }); diff --git a/core/src/interface.d.ts b/core/src/interface.d.ts index 70be4af1431..61489837cf7 100644 --- a/core/src/interface.d.ts +++ b/core/src/interface.d.ts @@ -24,7 +24,7 @@ export { PopoverOptions } from './components/popover/popover-interface'; export { RadioGroupCustomEvent } from './components/radio-group/radio-group-interface'; export { RangeCustomEvent, PinFormatter } from './components/range/range-interface'; export { RouterCustomEvent } from './components/router/utils/interface'; -export { RefresherCustomEvent } from './components/refresher/refresher-interface'; +export { RefresherCustomEvent, RefresherPullEndCustomEvent } from './components/refresher/refresher-interface'; export { ItemReorderCustomEvent, ReorderEndCustomEvent, diff --git a/packages/angular/src/directives/proxies.ts b/packages/angular/src/directives/proxies.ts index beaf411cec5..8a7e516d861 100644 --- a/packages/angular/src/directives/proxies.ts +++ b/packages/angular/src/directives/proxies.ts @@ -1810,12 +1810,13 @@ export class IonRefresher { constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) { c.detach(); this.el = r.nativeElement; - proxyOutputs(this, this.el, ['ionRefresh', 'ionPull', 'ionStart']); + proxyOutputs(this, this.el, ['ionRefresh', 'ionPull', 'ionStart', 'ionPullStart', 'ionPullEnd']); } } import type { RefresherEventDetail as IIonRefresherRefresherEventDetail } from '@ionic/core'; +import type { RefresherPullEndEventDetail as IIonRefresherRefresherPullEndEventDetail } from '@ionic/core'; export declare interface IonRefresher extends Components.IonRefresher { /** @@ -1831,8 +1832,19 @@ called when the async operation has completed. ionPull: EventEmitter>; /** * Emitted when the user begins to start pulling down. +TODO(FW-7044): Remove this in a major release @deprecated Use `ionPullStart` instead. */ ionStart: EventEmitter>; + /** + * Emitted when the user begins to start pulling down. + */ + ionPullStart: EventEmitter>; + /** + * Emitted when the refresher has returned to the inactive state +after a pull gesture. This fires whether the refresh completed +successfully or was canceled. + */ + ionPullEnd: EventEmitter>; } diff --git a/packages/angular/standalone/src/directives/proxies.ts b/packages/angular/standalone/src/directives/proxies.ts index 2fa0482d3d6..45ac278f56b 100644 --- a/packages/angular/standalone/src/directives/proxies.ts +++ b/packages/angular/standalone/src/directives/proxies.ts @@ -1664,12 +1664,13 @@ export class IonRefresher { constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) { c.detach(); this.el = r.nativeElement; - proxyOutputs(this, this.el, ['ionRefresh', 'ionPull', 'ionStart']); + proxyOutputs(this, this.el, ['ionRefresh', 'ionPull', 'ionStart', 'ionPullStart', 'ionPullEnd']); } } import type { RefresherEventDetail as IIonRefresherRefresherEventDetail } from '@ionic/core/components'; +import type { RefresherPullEndEventDetail as IIonRefresherRefresherPullEndEventDetail } from '@ionic/core/components'; export declare interface IonRefresher extends Components.IonRefresher { /** @@ -1685,8 +1686,19 @@ called when the async operation has completed. ionPull: EventEmitter>; /** * Emitted when the user begins to start pulling down. +TODO(FW-7044): Remove this in a major release @deprecated Use `ionPullStart` instead. */ ionStart: EventEmitter>; + /** + * Emitted when the user begins to start pulling down. + */ + ionPullStart: EventEmitter>; + /** + * Emitted when the refresher has returned to the inactive state +after a pull gesture. This fires whether the refresh completed +successfully or was canceled. + */ + ionPullEnd: EventEmitter>; } diff --git a/packages/vue/src/proxies.ts b/packages/vue/src/proxies.ts index 72e22d2c7b4..fca526032f3 100644 --- a/packages/vue/src/proxies.ts +++ b/packages/vue/src/proxies.ts @@ -783,11 +783,15 @@ export const IonRefresher: StencilVueComponent = /*@__PURE__*/ 'disabled', 'ionRefresh', 'ionPull', - 'ionStart' + 'ionStart', + 'ionPullStart', + 'ionPullEnd' ], [ 'ionRefresh', 'ionPull', - 'ionStart' + 'ionStart', + 'ionPullStart', + 'ionPullEnd' ]);