diff --git a/BREAKING.md b/BREAKING.md index 32b5eb74e1..7104eb17ba 100644 --- a/BREAKING.md +++ b/BREAKING.md @@ -19,6 +19,7 @@ This is a comprehensive list of the breaking changes introduced in the major ver - [Input](#version-7x-input) - [Overlays](#version-7x-overlays) - [Range](#version-7x-range) + - [Segment](#version-7x-segment) - [Slides](#version-7x-slides) - [Virtual Scroll](#version-7x-virtual-scroll) - [Utilities](#version-7x-utilities) @@ -91,6 +92,12 @@ iOS: |`$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`| +

Segment

+ +- `ionChange` is no longer emitted when the `value` of `ion-segment` is modified externally. `ionChange` is only emitted from user committed changes, such as clicking a segment button or dragging to activate a segment button. + +- The type signature of `value` supports `string | undefined`. Previously the type signature was `string | null | undefined`. + - Developers needing to clear the checked segment item should assign a value of `''` instead of `null`.

Slides

diff --git a/core/api.txt b/core/api.txt index 63eb7e4050..256ba9ffa7 100644 --- a/core/api.txt +++ b/core/api.txt @@ -1134,7 +1134,7 @@ ion-segment,prop,mode,"ios" | "md",undefined,false,false ion-segment,prop,scrollable,boolean,false,false,false ion-segment,prop,selectOnFocus,boolean,false,false,false ion-segment,prop,swipeGesture,boolean,true,false,false -ion-segment,prop,value,null | string | undefined,undefined,false,false +ion-segment,prop,value,string | undefined,undefined,false,false ion-segment,event,ionChange,SegmentChangeEventDetail,true ion-segment,css-prop,--background diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 970f74c838..1250e343fa 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -2455,7 +2455,7 @@ export namespace Components { /** * the value of the segment. */ - "value"?: string | null; + "value"?: string; } interface IonSegmentButton { /** @@ -6212,7 +6212,7 @@ declare namespace LocalJSX { */ "onIonChange"?: (event: IonSegmentCustomEvent) => void; /** - * Emitted when user has dragged over a new button + * Emitted when the value of the segment changes from user committed actions or from externally assigning a value. */ "onIonSelect"?: (event: IonSegmentCustomEvent) => void; /** @@ -6234,7 +6234,7 @@ declare namespace LocalJSX { /** * the value of the segment. */ - "value"?: string | null; + "value"?: string; } interface IonSegmentButton { /** diff --git a/core/src/components/segment/segment.tsx b/core/src/components/segment/segment.tsx index a08b8ee663..ac2bc1ac0f 100644 --- a/core/src/components/segment/segment.tsx +++ b/core/src/components/segment/segment.tsx @@ -22,11 +22,10 @@ import { createColorClasses, hostContext } from '../../utils/theme'; }) export class Segment implements ComponentInterface { private gesture?: Gesture; - private didInit = false; private checked?: HTMLIonSegmentButtonElement; - // Value to be emitted when gesture ends - private valueAfterGesture?: any; + // Value before the segment is dragged + private valueBeforeGesture?: string; @Element() el!: HTMLIonSegmentElement; @@ -76,18 +75,15 @@ export class Segment implements ComponentInterface { /** * the value of the segment. */ - @Prop({ mutable: true }) value?: string | null; + @Prop({ mutable: true }) value?: string; @Watch('value') - protected valueChanged(value: string | undefined, oldValue: string | undefined | null) { + protected valueChanged(value: string | undefined) { + /** + * `ionSelect` is emitted every time the value changes (internal or external changes). + * Used by `ion-segment-button` to determine if the button should be checked. + */ this.ionSelect.emit({ value }); - if (oldValue !== '' || this.didInit) { - if (!this.activated) { - this.ionChange.emit({ value }); - } else { - this.valueAfterGesture = value; - } - } } /** @@ -103,7 +99,9 @@ export class Segment implements ComponentInterface { @Event() ionChange!: EventEmitter; /** - * Emitted when user has dragged over a new button + * Emitted when the value of the segment changes from user committed actions + * or from externally assigning a value. + * * @internal */ @Event() ionSelect!: EventEmitter; @@ -157,10 +155,10 @@ export class Segment implements ComponentInterface { if (this.disabled) { this.disabledChanged(); } - this.didInit = true; } onStart(detail: GestureDetail) { + this.valueBeforeGesture = this.value; this.activate(detail); } @@ -179,11 +177,24 @@ export class Segment implements ComponentInterface { this.addRipple(detail); } - const value = this.valueAfterGesture; + const value = this.value; if (value !== undefined) { - this.ionChange.emit({ value }); - this.valueAfterGesture = undefined; + if (this.valueBeforeGesture !== value) { + this.emitValueChange(); + } } + this.valueBeforeGesture = undefined; + } + + /** + * 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() { + const { value } = this; + this.ionChange.emit({ value }); } private getButtons() { @@ -491,8 +502,11 @@ export class Segment implements ComponentInterface { } if (keyDownSelectsButton) { - const previous = this.checked || current; - this.checkButton(previous, current); + const previous = this.checked; + this.checkButton(this.checked || current, current); + if (current !== previous) { + this.emitValueChange(); + } } current.focus(); } diff --git a/core/src/components/segment/test/segment-events.e2e.ts b/core/src/components/segment/test/segment-events.e2e.ts new file mode 100644 index 0000000000..11008fffa3 --- /dev/null +++ b/core/src/components/segment/test/segment-events.e2e.ts @@ -0,0 +1,177 @@ +import { expect } from '@playwright/test'; +import { test } from '@utils/test/playwright'; + +test.describe('segment: events: ionChange', () => { + test.beforeEach(({ skip }) => { + skip.rtl(); + }); + + test.describe('when the segment is activated by keyboard navigation', () => { + test('should emit when there is no initial value', async ({ page, browserName }) => { + await page.setContent(` + + One + Two + Three + + `); + + const segment = page.locator('ion-segment'); + const ionChangeSpy = await page.spyOnEvent('ionChange'); + + const tabKey = browserName === 'webkit' ? 'Alt+Tab' : 'Tab'; + + await page.keyboard.press(tabKey); + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('Enter'); + + expect(await segment.evaluate((el: HTMLIonSegmentElement) => el.value)).toBe('2'); + + expect(ionChangeSpy).toHaveReceivedEventTimes(1); + expect(ionChangeSpy).toHaveReceivedEventDetail({ value: '2' }); + }); + }); + + test.describe('when the segment is clicked', () => { + test('should emit when the value changes', async ({ page }) => { + await page.setContent(` + + One + Two + Three + + `); + + const segment = page.locator('ion-segment'); + const ionChangeSpy = await page.spyOnEvent('ionChange'); + + await page.click('ion-segment-button[value="2"]'); + + await ionChangeSpy.next(); + + expect(await segment.evaluate((el: HTMLIonSegmentElement) => el.value)).toBe('2'); + + expect(ionChangeSpy).toHaveReceivedEventDetail({ value: '2' }); + expect(ionChangeSpy).toHaveReceivedEventTimes(1); + }); + + test('when the segment does not have an initial value', async ({ page }) => { + await page.setContent(` + + One + Two + Three + + `); + + const segment = page.locator('ion-segment'); + const ionChangeSpy = await page.spyOnEvent('ionChange'); + + await page.click('ion-segment-button[value="2"]'); + + await ionChangeSpy.next(); + + expect(await segment.evaluate((el: HTMLIonSegmentElement) => el.value)).toBe('2'); + + expect(ionChangeSpy).toHaveReceivedEventDetail({ value: '2' }); + expect(ionChangeSpy).toHaveReceivedEventTimes(1); + }); + }); + + test.describe('when the pointer is released', () => { + test('should emit if the value has changed', async ({ page }) => { + test.info().annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/20257', + }); + + await page.setContent(` + + + + One + Two + Three + + + + `); + + const ionChangeSpy = await page.spyOnEvent('ionChange'); + + const firstButton = page.locator('ion-segment-button[value="1"]'); + const lastButton = page.locator('ion-segment-button[value="3"]'); + + await firstButton.hover(); + await page.mouse.down(); + + await lastButton.hover(); + await page.mouse.up(); + + expect(ionChangeSpy).toHaveReceivedEventDetail({ value: '3' }); + expect(ionChangeSpy).toHaveReceivedEventTimes(1); + }); + + test('should not emit if the value has not changed', async ({ page }) => { + await page.setContent(` + + One + Two + Three + + `); + + const ionChangeSpy = await page.spyOnEvent('ionChange'); + + const firstButton = page.locator('ion-segment-button[value="1"]'); + const lastButton = page.locator('ion-segment-button[value="3"]'); + + await firstButton.hover(); + await page.mouse.down(); + + await lastButton.hover(); + + await firstButton.hover(); + await page.mouse.up(); + + expect(ionChangeSpy).toHaveReceivedEventTimes(0); + }); + }); + + test('should not emit if the value has not changed on click', async ({ page }) => { + await page.setContent(` + + One + Two + Three + + `); + + const segment = page.locator('ion-segment'); + const ionChangeSpy = await page.spyOnEvent('ionChange'); + + await page.click('ion-segment-button[value="1"]'); + + expect(await segment.evaluate((el: HTMLIonSegmentElement) => el.value)).toBe('1'); + + expect(ionChangeSpy).toHaveReceivedEventTimes(0); + }); + + test('should not emit if the value is set programmatically', async ({ page }) => { + await page.setContent(` + + One + Two + Three + + `); + + const segment = page.locator('ion-segment'); + const ionChangeSpy = await page.spyOnEvent('ionChange'); + + await segment.evaluate((el: HTMLIonSegmentElement) => (el.value = '2')); + + expect(ionChangeSpy).toHaveReceivedEventTimes(0); + expect(await segment.evaluate((el: HTMLIonSegmentElement) => el.value)).toBe('2'); + }); +});