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');
+ });
+});