From d1fb7b039b8e11e9d9ede850f90b977a46b52de8 Mon Sep 17 00:00:00 2001 From: Sean Perkins Date: Mon, 24 Oct 2022 16:13:48 -0400 Subject: [PATCH] feat(range): ionChange will only emit from user committed changes (#26089) --- BREAKING.md | 28 ++-- angular/src/directives/proxies.ts | 15 +- .../apps/ng12/src/app/form/form.component.ts | 4 +- .../apps/ng13/src/app/form/form.component.ts | 4 +- .../base/e2e/src/form-controls/range.spec.ts | 32 +++++ angular/test/base/e2e/src/form.spec.ts | 21 +-- angular/test/base/e2e/src/inputs.spec.ts | 14 +- .../test/base/src/app/app-routing.module.ts | 4 + .../range/range-routing.module.ts | 13 ++ .../form-controls/range/range.component.html | 16 +++ .../form-controls/range/range.component.ts | 18 +++ .../app/form-controls/range/range.module.ts | 19 +++ .../base/src/app/form/form.component.html | 5 - .../test/base/src/app/form/form.component.ts | 6 +- .../base/src/app/inputs/inputs.component.html | 14 -- .../base/src/app/inputs/inputs.component.ts | 10 +- core/api.txt | 3 +- core/src/components.d.ts | 12 +- core/src/components/range/range.tsx | 49 +++++-- .../components/range/test/range-events.e2e.ts | 131 ++++++++++++++++++ packages/vue/src/proxies.ts | 1 + 21 files changed, 328 insertions(+), 91 deletions(-) create mode 100644 angular/test/base/e2e/src/form-controls/range.spec.ts create mode 100644 angular/test/base/src/app/form-controls/range/range-routing.module.ts create mode 100644 angular/test/base/src/app/form-controls/range/range.component.html create mode 100644 angular/test/base/src/app/form-controls/range/range.component.ts create mode 100644 angular/test/base/src/app/form-controls/range/range.module.ts create mode 100644 core/src/components/range/test/range-events.e2e.ts diff --git a/BREAKING.md b/BREAKING.md index fbb21406e0..e0c1544c13 100644 --- a/BREAKING.md +++ b/BREAKING.md @@ -103,21 +103,27 @@ Ionic now listens on the `keydown` event instead of the `keyup` event when deter

Range

-Range is updated to align with the design specification for supported modes. +- Range is updated to align with the design specification for supported modes. -**Design tokens** + **Design tokens** + iOS: -iOS: + | Token | Previous Value | New Value | + | --------------------------------- | ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | + | `--bar-border-radius` | `0px` | `$range-ios-bar-border-radius` (`2px` default) | + | `--knob-size` | `28px` | `$range-ios-knob-width` (`26px` default) | + | `$range-ios-bar-height` | `2px` | `4px` | + | `$range-ios-bar-background-color` | `rgba(var(--ion-text-color-rgb, 0, 0, 0), .1)` | `var(--ion-color-step-900, #e6e6e6)` | + | `$range-ios-knob-box-shadow` | `0 3px 1px rgba(0, 0, 0, .1), 0 4px 8px rgba(0, 0, 0, .13), 0 0 0 1px rgba(0, 0, 0, .02)` | `0px 0.5px 4px rgba(0, 0, 0, 0.12), 0px 6px 13px rgba(0, 0, 0, 0.12)` | + | `$range-ios-knob-width` | `28px` | `26px` | -|Token|Previous Value|New Value| -|-----|--------------|---------| -|`--bar-border-radius`|`0px`|`$range-ios-bar-border-radius` (`2px` default)| -|`--knob-size`|`28px`|`$range-ios-knob-width` (`26px` default)| -|`$range-ios-bar-height`|`2px`|`4px`| -|`$range-ios-bar-background-color`|`rgba(var(--ion-text-color-rgb, 0, 0, 0), .1)`|`var(--ion-color-step-900, #e6e6e6)`| -|`$range-ios-knob-box-shadow`|`0 3px 1px rgba(0, 0, 0, .1), 0 4px 8px rgba(0, 0, 0, .13), 0 0 0 1px rgba(0, 0, 0, .02)`|`0px 0.5px 4px rgba(0, 0, 0, 0.12), 0px 6px 13px rgba(0, 0, 0, 0.12)`| -|`$range-ios-knob-width`|`28px`|`26px`| +- `ionChange` is no longer emitted when the `value` of `ion-range` is modified externally. `ionChange` is only emitted from user committed changes, such as dragging and releasing the range knob or selecting a new value with the keyboard arrows. + - If your application requires immediate feedback based on the user actively dragging the range knob, consider migrating your event listeners to using `ionInput` instead. + +- The `debounce` property's value value has changed from `0` to `undefined`. If `debounce` is undefined, the `ionInput` event will fire immediately. + +- Range no longer clamps assigned values within bounds. Developers will need to validate the value they are assigning to `ion-range` is within the `min` and `max` bounds when programmatically assigning a value. diff --git a/angular/src/directives/proxies.ts b/angular/src/directives/proxies.ts index d3eabd12ac..c72ceeb3a2 100644 --- a/angular/src/directives/proxies.ts +++ b/angular/src/directives/proxies.ts @@ -1321,9 +1321,20 @@ import type { RangeKnobMoveStartEventDetail as IRangeRangeKnobMoveStartEventDeta import type { RangeKnobMoveEndEventDetail as IRangeRangeKnobMoveEndEventDetail } from '@ionic/core'; export declare interface IonRange extends Components.IonRange { /** - * Emitted when the value property has changed. + * The `ionChange` event is fired for `` elements when the user +modifies the element's value: +- When the user releases the knob after dragging; +- When the user moves the knob with keyboard arrows + +`ionChange` is not fired when the value is changed programmatically. */ ionChange: EventEmitter>; + /** + * The `ionInput` event is fired for `` elements when the value +is modified. Unlike `ionChange`, `ionInput` is fired continuously +while the user is dragging the knob. + */ + ionInput: EventEmitter>; /** * Emitted when the range has focus. */ @@ -1360,7 +1371,7 @@ export class IonRange { constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) { c.detach(); this.el = r.nativeElement; - proxyOutputs(this, this.el, ['ionChange', 'ionFocus', 'ionBlur', 'ionKnobMoveStart', 'ionKnobMoveEnd']); + proxyOutputs(this, this.el, ['ionChange', 'ionInput', 'ionFocus', 'ionBlur', 'ionKnobMoveStart', 'ionKnobMoveEnd']); } } diff --git a/angular/test/apps/ng12/src/app/form/form.component.ts b/angular/test/apps/ng12/src/app/form/form.component.ts index 157bc9a1a8..beecc614db 100644 --- a/angular/test/apps/ng12/src/app/form/form.component.ts +++ b/angular/test/apps/ng12/src/app/form/form.component.ts @@ -19,7 +19,6 @@ export class FormComponent { input: ['', Validators.required], input2: ['Default Value'], checkbox: [false], - range: [5, Validators.min(10)], }, { updateOn: typeof (window as any) !== 'undefined' && window.location.hash === '#blur' ? 'blur' : 'change' }); @@ -41,8 +40,7 @@ export class FormComponent { toggle: true, input: 'Some value', input2: 'Another values', - checkbox: true, - range: 50 + checkbox: true }); } diff --git a/angular/test/apps/ng13/src/app/form/form.component.ts b/angular/test/apps/ng13/src/app/form/form.component.ts index 157bc9a1a8..beecc614db 100644 --- a/angular/test/apps/ng13/src/app/form/form.component.ts +++ b/angular/test/apps/ng13/src/app/form/form.component.ts @@ -19,7 +19,6 @@ export class FormComponent { input: ['', Validators.required], input2: ['Default Value'], checkbox: [false], - range: [5, Validators.min(10)], }, { updateOn: typeof (window as any) !== 'undefined' && window.location.hash === '#blur' ? 'blur' : 'change' }); @@ -41,8 +40,7 @@ export class FormComponent { toggle: true, input: 'Some value', input2: 'Another values', - checkbox: true, - range: 50 + checkbox: true }); } diff --git a/angular/test/base/e2e/src/form-controls/range.spec.ts b/angular/test/base/e2e/src/form-controls/range.spec.ts new file mode 100644 index 0000000000..386ba7ac60 --- /dev/null +++ b/angular/test/base/e2e/src/form-controls/range.spec.ts @@ -0,0 +1,32 @@ +describe('Form Controls: Range', () => { + + beforeEach(() => { + cy.visit('/form-controls/range'); + }); + + it('should have form control initial value', () => { + // Cypress does not support checking numeric values of custom elements + // see: https://github.com/cypress-io/cypress/blob/bf6560691436a5a953f7e03e0ea3de38f3d2a632/packages/driver/src/dom/elements/elementHelpers.ts#L7 + cy.get('ion-range').invoke('prop', 'value').should('eq', 5); + }); + + it('should reflect Ionic form control status classes', () => { + // Control is initially invalid + cy.get('ion-range').should('have.class', 'ion-invalid'); + cy.get('ion-range').should('have.class', 'ion-pristine'); + cy.get('ion-range').should('have.class', 'ion-untouched'); + + // Cypress does not support typing unless the element is focusable. + cy.get('ion-range').shadow() + .find('.range-knob-handle') + .click() + .focus() + .type('{rightarrow}'.repeat(5)); + + cy.get('ion-range').should('have.class', 'ion-valid'); + cy.get('ion-range').should('have.class', 'ion-dirty'); + cy.get('ion-range').should('have.class', 'ion-touched'); + cy.get('ion-range').invoke('prop', 'value').should('eq', 10); + }); + +}); diff --git a/angular/test/base/e2e/src/form.spec.ts b/angular/test/base/e2e/src/form.spec.ts index 6fe74df9f0..bb55004ae1 100644 --- a/angular/test/base/e2e/src/form.spec.ts +++ b/angular/test/base/e2e/src/form.spec.ts @@ -30,8 +30,7 @@ describe('Form', () => { toggle: false, input: '', input2: 'Default Value', - checkbox: false, - range: 5 + checkbox: false }); }); @@ -51,9 +50,6 @@ describe('Form', () => { // Click confirm button cy.get('ion-alert .alert-button:not(.alert-button-role-cancel)').click(); - testStatus('INVALID'); - - cy.get('ion-range').invoke('prop', 'value', 40); testStatus('VALID'); testData({ @@ -62,8 +58,7 @@ describe('Form', () => { toggle: false, input: 'Some value', input2: 'Default Value', - checkbox: false, - range: 40 + checkbox: false }); }); @@ -75,8 +70,7 @@ describe('Form', () => { toggle: true, input: '', input2: 'Default Value', - checkbox: false, - range: 5 + checkbox: false }); }); @@ -88,8 +82,7 @@ describe('Form', () => { toggle: false, input: '', input2: 'Default Value', - checkbox: true, - range: 5 + checkbox: true }); }); @@ -109,8 +102,7 @@ describe('Form', () => { toggle: true, input: '', input2: 'Default Value', - checkbox: false, - range: 5 + checkbox: false }); cy.get('ion-checkbox').click(); testData({ @@ -119,8 +111,7 @@ describe('Form', () => { toggle: true, input: '', input2: 'Default Value', - checkbox: true, - range: 5 + checkbox: true }); }); }); diff --git a/angular/test/base/e2e/src/inputs.spec.ts b/angular/test/base/e2e/src/inputs.spec.ts index f921c98d82..873ae2374f 100644 --- a/angular/test/base/e2e/src/inputs.spec.ts +++ b/angular/test/base/e2e/src/inputs.spec.ts @@ -9,7 +9,6 @@ describe('Inputs', () => { cy.get('ion-input').should('have.prop', 'value').and('equal', 'some text'); cy.get('ion-datetime').should('have.prop', 'value').and('equal', '1994-03-15'); cy.get('ion-select').should('have.prop', 'value').and('equal', 'nes'); - cy.get('ion-range').should('have.prop', 'value').and('equal', 10); }); it('should have reset value', () => { @@ -20,7 +19,6 @@ describe('Inputs', () => { cy.get('ion-input').should('have.prop', 'value').and('equal', ''); cy.get('ion-datetime').should('have.prop', 'value').and('equal', ''); cy.get('ion-select').should('have.prop', 'value').and('equal', ''); - cy.get('ion-range').should('have.prop', 'value').and('be.NaN'); }); it('should get some value', () => { @@ -32,7 +30,6 @@ describe('Inputs', () => { cy.get('ion-input').should('have.prop', 'value').and('equal', 'some text'); cy.get('ion-datetime').should('have.prop', 'value').and('equal', '1994-03-15'); cy.get('ion-select').should('have.prop', 'value').and('equal', 'nes'); - cy.get('ion-range').should('have.prop', 'value').and('equal', 10); }); it('change values should update angular', () => { @@ -54,19 +51,10 @@ describe('Inputs', () => { // Click confirm button cy.get('ion-alert .alert-button:not(.alert-button-role-cancel)').click(); - cy.get('ion-range').invoke('prop', 'value', 20); - cy.get('#checkbox-note').should('have.text', 'true'); cy.get('#toggle-note').should('have.text', 'true'); cy.get('#input-note').should('have.text', 'hola'); cy.get('#datetime-note').should('have.text', '1994-03-14'); cy.get('#select-note').should('have.text', 'ps'); - cy.get('#range-note').should('have.text', '20'); }); - - it('nested components should not interfere with NgModel', () => { - cy.get('#range-note').should('have.text', '10'); - cy.get('#nested-toggle').click(); - cy.get('#range-note').should('have.text', '10'); - }); -}) +}); diff --git a/angular/test/base/src/app/app-routing.module.ts b/angular/test/base/src/app/app-routing.module.ts index d2e421c7a5..b6efd0f479 100644 --- a/angular/test/base/src/app/app-routing.module.ts +++ b/angular/test/base/src/app/app-routing.module.ts @@ -69,6 +69,10 @@ const routes: Routes = [ } ] }, + { + path: 'form-controls/range', + loadChildren: () => import('./form-controls/range/range.module').then(m => m.RangeModule) + } ]; @NgModule({ diff --git a/angular/test/base/src/app/form-controls/range/range-routing.module.ts b/angular/test/base/src/app/form-controls/range/range-routing.module.ts new file mode 100644 index 0000000000..b42aaf651a --- /dev/null +++ b/angular/test/base/src/app/form-controls/range/range-routing.module.ts @@ -0,0 +1,13 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; + +import { RangeComponent } from './range.component'; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { path: '', component: RangeComponent } + ]) + ] +}) +export class RangeRoutingModule { } diff --git a/angular/test/base/src/app/form-controls/range/range.component.html b/angular/test/base/src/app/form-controls/range/range.component.html new file mode 100644 index 0000000000..91c7df9d62 --- /dev/null +++ b/angular/test/base/src/app/form-controls/range/range.component.html @@ -0,0 +1,16 @@ + + + Range + + + +
+ + + Range + + + + Submit +
+
diff --git a/angular/test/base/src/app/form-controls/range/range.component.ts b/angular/test/base/src/app/form-controls/range/range.component.ts new file mode 100644 index 0000000000..fa471e740e --- /dev/null +++ b/angular/test/base/src/app/form-controls/range/range.component.ts @@ -0,0 +1,18 @@ +import { Component } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; + +@Component({ + selector: 'app-range', + templateUrl: './range.component.html' +}) +export class RangeComponent { + + form: FormGroup; + + constructor(private fb: FormBuilder) { + this.form = this.fb.group({ + range: [5, Validators.min(10)] + }); + } + +} diff --git a/angular/test/base/src/app/form-controls/range/range.module.ts b/angular/test/base/src/app/form-controls/range/range.module.ts new file mode 100644 index 0000000000..a665a40653 --- /dev/null +++ b/angular/test/base/src/app/form-controls/range/range.module.ts @@ -0,0 +1,19 @@ +import { NgModule } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { IonicModule } from '@ionic/angular'; + +import { RangeRoutingModule } from './range-routing.module'; +import { RangeComponent } from './range.component'; + +@NgModule({ + imports: [ + FormsModule, + ReactiveFormsModule, + IonicModule, + RangeRoutingModule + ], + declarations: [ + RangeComponent + ] +}) +export class RangeModule { } diff --git a/angular/test/base/src/app/form/form.component.html b/angular/test/base/src/app/form/form.component.html index c9426e5fca..b7c73e4c32 100644 --- a/angular/test/base/src/app/form/form.component.html +++ b/angular/test/base/src/app/form/form.component.html @@ -51,11 +51,6 @@ - - Range - - -

Form Status: {{ profileForm.status }} diff --git a/angular/test/base/src/app/form/form.component.ts b/angular/test/base/src/app/form/form.component.ts index 65c6f5a4ce..0e9499d9bb 100644 --- a/angular/test/base/src/app/form/form.component.ts +++ b/angular/test/base/src/app/form/form.component.ts @@ -18,8 +18,7 @@ export class FormComponent { toggle: [false], input: ['', Validators.required], input2: ['Default Value'], - checkbox: [false], - range: [5, Validators.min(10)], + checkbox: [false] }, { updateOn: typeof (window as any) !== 'undefined' && window.location.hash === '#blur' ? 'blur' : 'change' }); @@ -41,8 +40,7 @@ export class FormComponent { toggle: true, input: 'Some value', input2: 'Another values', - checkbox: true, - range: 50 + checkbox: true }); } diff --git a/angular/test/base/src/app/inputs/inputs.component.html b/angular/test/base/src/app/inputs/inputs.component.html index 60f13a7b95..65391e5731 100644 --- a/angular/test/base/src/app/inputs/inputs.component.html +++ b/angular/test/base/src/app/inputs/inputs.component.html @@ -85,20 +85,6 @@ {{checkbox}} - - Range - - {{range}} - - - - Range Mirror - - - - {{range}} - -

Set values diff --git a/angular/test/base/src/app/inputs/inputs.component.ts b/angular/test/base/src/app/inputs/inputs.component.ts index 78f14765a0..abcbc6a8a0 100644 --- a/angular/test/base/src/app/inputs/inputs.component.ts +++ b/angular/test/base/src/app/inputs/inputs.component.ts @@ -6,12 +6,11 @@ import { Component } from '@angular/core'; }) export class InputsComponent { - datetime = '1994-03-15'; - input = 'some text'; + datetime? = '1994-03-15'; + input? = 'some text'; checkbox = true; toggle = true; - select = 'nes'; - range = 10; + select? = 'nes'; changes = 0; setValues() { @@ -21,7 +20,6 @@ export class InputsComponent { this.checkbox = true; this.toggle = true; this.select = 'nes'; - this.range = 10; } resetValues() { @@ -31,8 +29,8 @@ export class InputsComponent { this.checkbox = false; this.toggle = false; this.select = undefined; - this.range = undefined; } + counter() { this.changes++; return Math.floor(this.changes / 2); diff --git a/core/api.txt b/core/api.txt index 5494461da3..9a9ad389c9 100644 --- a/core/api.txt +++ b/core/api.txt @@ -982,7 +982,7 @@ ion-radio-group,event,ionChange,RadioGroupChangeEventDetail,true ion-range,shadow ion-range,prop,activeBarStart,number | undefined,undefined,false,false ion-range,prop,color,"danger" | "dark" | "light" | "medium" | "primary" | "secondary" | "success" | "tertiary" | "warning" | string & Record | undefined,undefined,false,true -ion-range,prop,debounce,number,0,false,false +ion-range,prop,debounce,number | undefined,undefined,false,false ion-range,prop,disabled,boolean,false,false,false ion-range,prop,dualKnobs,boolean,false,false,false ion-range,prop,max,number,100,false,false @@ -998,6 +998,7 @@ ion-range,prop,value,number | { lower: number; upper: number; },0,false,false ion-range,event,ionBlur,void,true ion-range,event,ionChange,RangeChangeEventDetail,true ion-range,event,ionFocus,void,true +ion-range,event,ionInput,RangeChangeEventDetail,true ion-range,event,ionKnobMoveEnd,RangeKnobMoveEndEventDetail,true ion-range,event,ionKnobMoveStart,RangeKnobMoveStartEventDetail,true ion-range,css-prop,--bar-background diff --git a/core/src/components.d.ts b/core/src/components.d.ts index a282dd6f5d..ff0cf3c212 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -2109,9 +2109,9 @@ export namespace Components { */ "color"?: Color; /** - * How long, in milliseconds, to wait to trigger the `ionChange` event after each change in the range value. This also impacts form bindings such as `ngModel` or `v-model`. + * How long, in milliseconds, to wait to trigger the `ionInput` event after each change in the range value. */ - "debounce": number; + "debounce"?: number; /** * If `true`, the user cannot interact with the range. */ @@ -5843,7 +5843,7 @@ declare namespace LocalJSX { */ "color"?: Color; /** - * How long, in milliseconds, to wait to trigger the `ionChange` event after each change in the range value. This also impacts form bindings such as `ngModel` or `v-model`. + * How long, in milliseconds, to wait to trigger the `ionInput` event after each change in the range value. */ "debounce"?: number; /** @@ -5875,13 +5875,17 @@ declare namespace LocalJSX { */ "onIonBlur"?: (event: IonRangeCustomEvent) => void; /** - * Emitted when the value property has changed. + * The `ionChange` event is fired for `` elements when the user modifies the element's value: - When the user releases the knob after dragging; - When the user moves the knob with keyboard arrows `ionChange` is not fired when the value is changed programmatically. */ "onIonChange"?: (event: IonRangeCustomEvent) => void; /** * Emitted when the range has focus. */ "onIonFocus"?: (event: IonRangeCustomEvent) => void; + /** + * The `ionInput` event is fired for `` elements when the value is modified. Unlike `ionChange`, `ionInput` is fired continuously while the user is dragging the knob. + */ + "onIonInput"?: (event: IonRangeCustomEvent) => void; /** * Emitted when the user finishes moving the range knob, whether through mouse drag, touch gesture, or keyboard interaction. */ diff --git a/core/src/components/range/range.tsx b/core/src/components/range/range.tsx index 622cbea86c..f11c69a2dc 100644 --- a/core/src/components/range/range.tsx +++ b/core/src/components/range/range.tsx @@ -54,6 +54,7 @@ export class Range implements ComponentInterface { private inheritedAttributes: Attributes = {}; private contentEl: HTMLElement | null = null; private initialContentScrollY = true; + private originalIonInput?: EventEmitter; @Element() el!: HTMLIonRangeElement; @@ -70,14 +71,18 @@ export class Range implements ComponentInterface { /** * How long, in milliseconds, to wait to trigger the - * `ionChange` event after each change in the range value. - * This also impacts form bindings such as `ngModel` or `v-model`. + * `ionInput` event after each change in the range value. */ - @Prop() debounce = 0; + @Prop() debounce?: number; @Watch('debounce') protected debounceChanged() { - this.ionChange = debounceEvent(this.ionChange, this.debounce); + const { ionInput, debounce, originalIonInput } = this; + /** + * If debounce is undefined, we have to manually revert the ionInput emitter in case + * debounce used to be set to a number. Otherwise, the event would stay debounced. + */ + this.ionInput = debounce === undefined ? originalIonInput ?? ionInput : debounceEvent(ionInput, debounce); } // TODO: In Ionic Framework v6 this should initialize to this.rangeId like the other form components do. @@ -185,14 +190,10 @@ export class Range implements ComponentInterface { */ @Prop({ mutable: true }) value: RangeValue = 0; @Watch('value') - protected valueChanged(value: RangeValue) { + protected valueChanged() { if (!this.noUpdate) { this.updateRatio(); } - - value = this.ensureValueInBounds(value); - - this.ionChange.emit({ value }); } private clampBounds = (value: any): number => { @@ -211,10 +212,22 @@ export class Range implements ComponentInterface { }; /** - * Emitted when the value property has changed. + * The `ionChange` event is fired for `` elements when the user + * modifies the element's value: + * - When the user releases the knob after dragging; + * - When the user moves the knob with keyboard arrows + * + * `ionChange` is not fired when the value is changed programmatically. */ @Event() ionChange!: EventEmitter; + /** + * The `ionInput` event is fired for `` elements when the value + * is modified. Unlike `ionChange`, `ionInput` is fired continuously + * while the user is dragging the knob. + */ + @Event() ionInput!: EventEmitter; + /** * Emitted when the styles change. * @internal @@ -270,6 +283,7 @@ export class Range implements ComponentInterface { } componentDidLoad() { + this.originalIonInput = this.ionInput; this.setupGesture(); this.didLoad = true; } @@ -317,6 +331,7 @@ export class Range implements ComponentInterface { this.ionKnobMoveStart.emit({ value: ensureValueInBounds(this.value) }); this.updateValue(); + this.emitValueChange(); this.ionKnobMoveEnd.emit({ value: ensureValueInBounds(this.value) }); }; private getValue(): RangeValue { @@ -344,6 +359,17 @@ export class Range implements ComponentInterface { }); } + /** + * Emits an `ionChange` event. + * + * This API should be called for user committed changes. + * This API should not be used for external value changes. + */ + private emitValueChange() { + this.value = this.ensureValueInBounds(this.value); + this.ionChange.emit({ value: this.value }); + } + private onStart(detail: GestureDetail) { const { contentEl } = this; if (contentEl) { @@ -382,6 +408,7 @@ export class Range implements ComponentInterface { this.update(detail.currentX); this.pressedKnob = undefined; + this.emitValueChange(); this.ionKnobMoveEnd.emit({ value: this.ensureValueInBounds(this.value) }); } @@ -458,6 +485,8 @@ export class Range implements ComponentInterface { upper: Math.max(valA, valB), }; + this.ionInput.emit({ value: this.value }); + this.noUpdate = false; } diff --git a/core/src/components/range/test/range-events.e2e.ts b/core/src/components/range/test/range-events.e2e.ts new file mode 100644 index 0000000000..6513197f5b --- /dev/null +++ b/core/src/components/range/test/range-events.e2e.ts @@ -0,0 +1,131 @@ +import { expect } from '@playwright/test'; +import { test } from '@utils/test/playwright'; + +test.describe('range: events:', () => { + test.beforeEach(({ skip }) => { + skip.rtl(); + skip.mode('md'); + }); + + test.describe(' ionChange', () => { + test('should not emit if the value is set programmatically', async ({ page }) => { + await page.setContent(``); + + const range = page.locator('ion-range'); + const ionChangeSpy = await page.spyOnEvent('ionChange'); + + await range.evaluate((el: HTMLIonRangeElement) => { + el.value = 50; + }); + + await page.waitForChanges(); + + expect(ionChangeSpy).toHaveReceivedEventTimes(0); + + // Update the value again to make sure it doesn't emit a second time + await range.evaluate((el: HTMLIonRangeElement) => { + el.value = 60; + }); + + await page.waitForChanges(); + + expect(ionChangeSpy).toHaveReceivedEventTimes(0); + }); + + test('should emit when the knob is released', async ({ page }) => { + await page.setContent(``); + + const rangeHandle = page.locator('ion-range .range-knob-handle'); + const ionChangeSpy = await page.spyOnEvent('ionChange'); + + const boundingBox = await rangeHandle.boundingBox(); + + await rangeHandle.hover(); + await page.mouse.down(); + await page.mouse.move(boundingBox!.x + 100, boundingBox!.y); + + await page.mouse.up(); + + await ionChangeSpy.next(); + + expect(ionChangeSpy).toHaveReceivedEventTimes(1); + }); + + test('should emit when the knob is moved with the keyboard', async ({ page }) => { + await page.setContent(``); + + const rangeHandle = page.locator('ion-range .range-knob-handle'); + const ionChangeSpy = await page.spyOnEvent('ionChange'); + + await rangeHandle.click(); + + await page.keyboard.press('ArrowLeft'); + await ionChangeSpy.next(); + + expect(ionChangeSpy).toHaveReceivedEventDetail({ value: 49 }); + + await page.keyboard.press('ArrowRight'); + await ionChangeSpy.next(); + + expect(ionChangeSpy).toHaveReceivedEventDetail({ value: 50 }); + + await page.keyboard.press('ArrowUp'); + await ionChangeSpy.next(); + + expect(ionChangeSpy).toHaveReceivedEventDetail({ value: 51 }); + + await page.keyboard.press('ArrowDown'); + await ionChangeSpy.next(); + + expect(ionChangeSpy).toHaveReceivedEventDetail({ value: 50 }); + }); + }); + + test.describe('ionInput', () => { + test('should emit when the knob is dragged', async ({ page }) => { + await page.setContent(``); + + const rangeHandle = page.locator('ion-range .range-knob-handle'); + const ionInputSpy = await page.spyOnEvent('ionInput'); + + const boundingBox = await rangeHandle.boundingBox(); + + await rangeHandle.hover(); + await page.mouse.down(); + await page.mouse.move(boundingBox!.x + 100, boundingBox!.y); + + await ionInputSpy.next(); + + expect(ionInputSpy).toHaveReceivedEvent(); + }); + + test('should emit when the knob is moved with the keyboard', async ({ page }) => { + await page.setContent(``); + + const rangeHandle = page.locator('ion-range .range-knob-handle'); + const ionInputSpy = await page.spyOnEvent('ionInput'); + + await rangeHandle.click(); + + await page.keyboard.press('ArrowLeft'); + await ionInputSpy.next(); + + expect(ionInputSpy).toHaveReceivedEventDetail({ value: 49 }); + + await page.keyboard.press('ArrowRight'); + await ionInputSpy.next(); + + expect(ionInputSpy).toHaveReceivedEventDetail({ value: 50 }); + + await page.keyboard.press('ArrowUp'); + await ionInputSpy.next(); + + expect(ionInputSpy).toHaveReceivedEventDetail({ value: 51 }); + + await page.keyboard.press('ArrowDown'); + await ionInputSpy.next(); + + expect(ionInputSpy).toHaveReceivedEventDetail({ value: 50 }); + }); + }); +}); diff --git a/packages/vue/src/proxies.ts b/packages/vue/src/proxies.ts index 89ab65a6c8..38214f38e7 100644 --- a/packages/vue/src/proxies.ts +++ b/packages/vue/src/proxies.ts @@ -601,6 +601,7 @@ export const IonRange = /*@__PURE__*/ defineContainer('ion-range', 'disabled', 'value', 'ionChange', + 'ionInput', 'ionStyle', 'ionFocus', 'ionBlur',