diff --git a/angular/src/directives/proxies.ts b/angular/src/directives/proxies.ts index a323fedfb7..8b098ac0f4 100644 --- a/angular/src/directives/proxies.ts +++ b/angular/src/directives/proxies.ts @@ -1272,6 +1272,8 @@ export class IonRadioGroup { } import type { RangeChangeEventDetail as IRangeRangeChangeEventDetail } from '@ionic/core'; +import type { RangeKnobMoveStartEventDetail as IRangeRangeKnobMoveStartEventDetail } from '@ionic/core'; +import type { RangeKnobMoveEndEventDetail as IRangeRangeKnobMoveEndEventDetail } from '@ionic/core'; export declare interface IonRange extends Components.IonRange { /** * Emitted when the value property has changed. @@ -1285,6 +1287,16 @@ export declare interface IonRange extends Components.IonRange { * Emitted when the range loses focus. */ ionBlur: EventEmitter>; + /** + * Emitted when the user starts moving the range knob, whether through +mouse drag, touch gesture, or keyboard interaction. + */ + ionKnobMoveStart: EventEmitter>; + /** + * Emitted when the user finishes moving the range knob, whether through +mouse drag, touch gesture, or keyboard interaction. + */ + ionKnobMoveEnd: EventEmitter>; } @@ -1303,7 +1315,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']); + proxyOutputs(this, this.el, ['ionChange', 'ionFocus', 'ionBlur', 'ionKnobMoveStart', 'ionKnobMoveEnd']); } } diff --git a/core/api.txt b/core/api.txt index 697f911a14..42482b3967 100644 --- a/core/api.txt +++ b/core/api.txt @@ -984,6 +984,8 @@ 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,ionKnobMoveEnd,RangeKnobMoveEndEventDetail,true +ion-range,event,ionKnobMoveStart,RangeKnobMoveStartEventDetail,true ion-range,css-prop,--bar-background ion-range,css-prop,--bar-background-active ion-range,css-prop,--bar-border-radius diff --git a/core/src/components.d.ts b/core/src/components.d.ts index f93983dc60..edc796aee2 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, ActionSheetAttributes, ActionSheetButton, AlertButton, AlertInput, AnimationBuilder, AutocompleteTypes, BreadcrumbCollapsedClickEventDetail, CheckboxChangeEventDetail, Color, ComponentProps, ComponentRef, DatetimeChangeEventDetail, DomRenderFn, FooterHeightFn, FrameworkDelegate, HeaderFn, HeaderHeightFn, InputChangeEventDetail, ItemHeightFn, ItemRenderFn, ItemReorderEventDetail, LoadingAttributes, MenuChangeEventDetail, ModalAttributes, ModalBreakpointChangeEventDetail, NavComponent, NavComponentWithProps, NavOptions, OverlayEventDetail, PickerAttributes, PickerButton, PickerColumn, PopoverAttributes, 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 { AccordionGroupChangeEventDetail, ActionSheetAttributes, ActionSheetButton, AlertButton, AlertInput, AnimationBuilder, AutocompleteTypes, BreadcrumbCollapsedClickEventDetail, CheckboxChangeEventDetail, Color, ComponentProps, ComponentRef, DatetimeChangeEventDetail, DomRenderFn, FooterHeightFn, FrameworkDelegate, HeaderFn, HeaderHeightFn, InputChangeEventDetail, ItemHeightFn, ItemRenderFn, ItemReorderEventDetail, LoadingAttributes, MenuChangeEventDetail, ModalAttributes, ModalBreakpointChangeEventDetail, NavComponent, NavComponentWithProps, NavOptions, OverlayEventDetail, PickerAttributes, PickerButton, PickerColumn, PopoverAttributes, PopoverSize, PositionAlign, PositionReference, PositionSide, RadioGroupChangeEventDetail, RangeChangeEventDetail, RangeKnobMoveEndEventDetail, RangeKnobMoveStartEventDetail, 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 { AlertAttributes } from "./components/alert/alert-interface"; import { CounterFormatter } from "./components/item/item-interface"; @@ -5771,6 +5771,14 @@ declare namespace LocalJSX { * Emitted when the range has focus. */ "onIonFocus"?: (event: CustomEvent) => void; + /** + * Emitted when the user finishes moving the range knob, whether through mouse drag, touch gesture, or keyboard interaction. + */ + "onIonKnobMoveEnd"?: (event: CustomEvent) => void; + /** + * Emitted when the user starts moving the range knob, whether through mouse drag, touch gesture, or keyboard interaction. + */ + "onIonKnobMoveStart"?: (event: CustomEvent) => void; /** * Emitted when the styles change. */ diff --git a/core/src/components/range/range-interface.ts b/core/src/components/range/range-interface.ts index f1e651c7f6..f6111daba6 100644 --- a/core/src/components/range/range-interface.ts +++ b/core/src/components/range/range-interface.ts @@ -8,7 +8,15 @@ export interface RangeChangeEventDetail { value: RangeValue; } +export interface RangeKnobMoveStartEventDetail { + value: RangeValue; +} + +export interface RangeKnobMoveEndEventDetail { + value: RangeValue; +} + export interface RangeCustomEvent extends CustomEvent { - detail: RangeChangeEventDetail; + detail: RangeChangeEventDetail | RangeKnobMoveStartEventDetail | RangeKnobMoveEndEventDetail; target: HTMLIonRangeElement; } diff --git a/core/src/components/range/range.tsx b/core/src/components/range/range.tsx index 93dea8a662..bbd00a1d80 100644 --- a/core/src/components/range/range.tsx +++ b/core/src/components/range/range.tsx @@ -1,7 +1,7 @@ import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Prop, State, Watch, h } from '@stencil/core'; import { getIonMode } from '../../global/ionic-global'; -import { Color, Gesture, GestureDetail, KnobName, RangeChangeEventDetail, RangeValue, StyleEventDetail } from '../../interface'; +import { Color, Gesture, GestureDetail, KnobName, RangeChangeEventDetail, RangeKnobMoveEndEventDetail, RangeKnobMoveStartEventDetail, RangeValue, StyleEventDetail } from '../../interface'; import { Attributes, clamp, debounceEvent, getAriaLabel, inheritAttributes, renderHiddenInput } from '../../utils/helpers'; import { isRTL } from '../../utils/rtl'; import { createColorClasses, hostContext } from '../../utils/theme'; @@ -191,6 +191,18 @@ export class Range implements ComponentInterface { */ @Event() ionBlur!: EventEmitter; + /** + * Emitted when the user starts moving the range knob, whether through + * mouse drag, touch gesture, or keyboard interaction. + */ + @Event() ionKnobMoveStart!: EventEmitter; + + /** + * Emitted when the user finishes moving the range knob, whether through + * mouse drag, touch gesture, or keyboard interaction. + */ + @Event() ionKnobMoveEnd!: EventEmitter; + private setupGesture = async () => { const rangeSlider = this.rangeSlider; if (rangeSlider) { @@ -246,6 +258,8 @@ export class Range implements ComponentInterface { } private handleKeyboard = (knob: KnobName, isIncrease: boolean) => { + const { ensureValueInBounds } = this; + let step = this.step; step = step > 0 ? step : 1; step = step / (this.max - this.min); @@ -257,7 +271,10 @@ export class Range implements ComponentInterface { } else { this.ratioB = clamp(0, this.ratioB + step, 1); } + + this.ionKnobMoveStart.emit({ value: ensureValueInBounds(this.value) }); this.updateValue(); + this.ionKnobMoveEnd.emit({ value: ensureValueInBounds(this.value) }); } private getValue(): RangeValue { @@ -305,6 +322,8 @@ export class Range implements ComponentInterface { // update the active knob's position this.update(currentX); + + this.ionKnobMoveStart.emit({ value: this.ensureValueInBounds(this.value) }); } private onMove(detail: GestureDetail) { @@ -314,6 +333,8 @@ export class Range implements ComponentInterface { private onEnd(detail: GestureDetail) { this.update(detail.currentX); this.pressedKnob = undefined; + + this.ionKnobMoveEnd.emit({ value: this.ensureValueInBounds(this.value) }); } private update(currentX: number) { diff --git a/core/src/components/range/readme.md b/core/src/components/range/readme.md index 831cc16ef6..00ca549144 100644 --- a/core/src/components/range/readme.md +++ b/core/src/components/range/readme.md @@ -25,6 +25,22 @@ interface RangeChangeEventDetail { } ``` +### RangeKnobMoveStartEventDetail + +```typescript +interface RangeKnobMoveStartEventDetail { + value: RangeValue; +} +``` + +### RangeKnobMoveEndEventDetail + +```typescript +interface RangeKnobMoveEndEventDetail { + value: RangeValue; +} +``` + ### RangeCustomEvent While not required, this interface can be used in place of the `CustomEvent` interface for stronger typing with Ionic events emitted from this component. @@ -368,11 +384,13 @@ export default defineComponent({ ## Events -| Event | Description | Type | -| ----------- | -------------------------------------------- | ------------------------------------- | -| `ionBlur` | Emitted when the range loses focus. | `CustomEvent` | -| `ionChange` | Emitted when the value property has changed. | `CustomEvent` | -| `ionFocus` | Emitted when the range has focus. | `CustomEvent` | +| Event | Description | Type | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | +| `ionBlur` | Emitted when the range loses focus. | `CustomEvent` | +| `ionChange` | Emitted when the value property has changed. | `CustomEvent` | +| `ionFocus` | Emitted when the range has focus. | `CustomEvent` | +| `ionKnobMoveEnd` | Emitted when the user finishes moving the range knob, whether through mouse drag, touch gesture, or keyboard interaction. | `CustomEvent` | +| `ionKnobMoveStart` | Emitted when the user starts moving the range knob, whether through mouse drag, touch gesture, or keyboard interaction. | `CustomEvent` | ## Slots diff --git a/core/src/components/range/test/basic/e2e.ts b/core/src/components/range/test/basic/e2e.ts index 64b945abae..24429f5b6d 100644 --- a/core/src/components/range/test/basic/e2e.ts +++ b/core/src/components/range/test/basic/e2e.ts @@ -1,4 +1,5 @@ import { newE2EPage } from '@stencil/core/testing'; +import { dragElementBy } from '@utils/test'; test('range: basic', async () => { const page = await newE2EPage({ @@ -17,3 +18,45 @@ test('range:rtl: basic', async () => { const compare = await page.compareScreenshot(); expect(compare).toMatchScreenshot(); }); + +test('range: start/end events', async () => { + const page = await newE2EPage({ + url: '/src/components/range/test/basic?ionic:_testing=true' + }); + + const rangeStart = await page.spyOnEvent('ionKnobMoveStart'); + const rangeEnd = await page.spyOnEvent('ionKnobMoveEnd'); + const rangeEl = await page.$('#basic'); + + await dragElementBy(rangeEl, page, 300, 0); + + /** + * dragElementBy defaults to starting the drag from the middle of the el, + * so the start value should jump to 50 despite the range defaulting to 20. + */ + expect(rangeStart).toHaveReceivedEventDetail({ value: 50 }); + expect(rangeEnd).toHaveReceivedEventDetail({ value: 91 }); + + /** + * Verify both events fire if range is clicked without dragging. + */ + await dragElementBy(rangeEl, page, 0, 0); + + expect(rangeStart).toHaveReceivedEventDetail({ value: 50 }); + expect(rangeEnd).toHaveReceivedEventDetail({ value: 50 }) +}); + +test('range: start/end events, keyboard', async () => { + const page = await newE2EPage({ + url: '/src/components/range/test/basic?ionic:_testing=true' + }); + + const rangeStart = await page.spyOnEvent('ionKnobMoveStart'); + const rangeEnd = await page.spyOnEvent('ionKnobMoveEnd'); + + await page.keyboard.press('Tab'); // focus first range + await page.keyboard.press('ArrowRight'); + + expect(rangeStart).toHaveReceivedEventDetail({ value: 20 }); + expect(rangeEnd).toHaveReceivedEventDetail({ value: 21 }); +}); \ No newline at end of file diff --git a/core/src/components/range/test/basic/index.html b/core/src/components/range/test/basic/index.html index 617e33f818..0300a2cf20 100644 --- a/core/src/components/range/test/basic/index.html +++ b/core/src/components/range/test/basic/index.html @@ -66,7 +66,7 @@ - + @@ -240,19 +240,7 @@ knob.value = { lower: 33, upper: 60 - } - knob.addEventListener('ionFocus', function (ev) { - console.log('focus', ev) - }) - knob.addEventListener('ionBlur', function (ev) { - console.log('blur', ev) - }) - knob.addEventListener('ionChange', function (ev) { - console.log('change', ev) - }) - debounceRange.addEventListener('ionChange', function (ev) { - console.log('change', ev) - }) + }; function elTest() { var range = document.getElementById('range'); diff --git a/packages/vue/src/proxies.ts b/packages/vue/src/proxies.ts index 4964a67998..ad92c222d1 100644 --- a/packages/vue/src/proxies.ts +++ b/packages/vue/src/proxies.ts @@ -591,7 +591,9 @@ export const IonRange = /*@__PURE__*/ defineContainer('ion-range', 'ionChange', 'ionStyle', 'ionFocus', - 'ionBlur' + 'ionBlur', + 'ionKnobMoveStart', + 'ionKnobMoveEnd' ], 'value', 'v-ion-change', 'ionChange');