diff --git a/core/api.txt b/core/api.txt index 9c146fa6a4f..d7182609d84 100644 --- a/core/api.txt +++ b/core/api.txt @@ -1542,7 +1542,7 @@ ion-segment,css-prop,--background,md ion-segment-button,shadow ion-segment-button,prop,contentId,string | undefined,undefined,false,true -ion-segment-button,prop,disabled,boolean,false,false,false +ion-segment-button,prop,disabled,boolean,false,false,true ion-segment-button,prop,layout,"icon-bottom" | "icon-end" | "icon-hide" | "icon-start" | "icon-top" | "label-hide" | undefined,'icon-top',false,false ion-segment-button,prop,mode,"ios" | "md",undefined,false,false ion-segment-button,prop,type,"button" | "reset" | "submit",'button',false,false @@ -1608,13 +1608,11 @@ ion-segment-button,part,indicator-background ion-segment-button,part,native ion-segment-content,shadow -ion-segment-content,prop,disabled,boolean,false,false,false ion-segment-view,shadow ion-segment-view,prop,disabled,boolean,false,false,false -ion-segment-view,method,setContent,setContent(id: string, smoothScroll?: boolean) => Promise -ion-segment-view,event,ionSegmentViewScroll,{ scrollDirection: string; scrollDistance: number; scrollDistancePercentage: number; },true -ion-segment-view,event,ionSegmentViewScrollEnd,{ activeContentId: string; },true +ion-segment-view,event,ionSegmentViewScroll,SegmentViewScrollEvent,true +ion-segment-view,event,ionSegmentViewScrollEnd,void,true ion-segment-view,event,ionSegmentViewScrollStart,void,true ion-select,shadow diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 9e6042b40a8..e1ac2b5c4ed 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -34,6 +34,7 @@ import { NavigationHookCallback } from "./components/route/route-interface"; import { SearchbarChangeEventDetail, SearchbarInputEventDetail } from "./components/searchbar/searchbar-interface"; import { SegmentChangeEventDetail, SegmentValue } from "./components/segment/segment-interface"; import { SegmentButtonLayout } from "./components/segment-button/segment-button-interface"; +import { SegmentViewScrollEvent } from "./components/segment-view/segment-view-interface"; import { SelectChangeEventDetail, SelectCompareFn, SelectInterface } from "./components/select/select-interface"; import { SelectPopoverOption } from "./components/select-popover/select-popover-interface"; import { TabBarChangedEventDetail, TabButtonClickEventDetail, TabButtonLayout } from "./components/tab-bar/tab-bar-interface"; @@ -69,6 +70,7 @@ export { NavigationHookCallback } from "./components/route/route-interface"; export { SearchbarChangeEventDetail, SearchbarInputEventDetail } from "./components/searchbar/searchbar-interface"; export { SegmentChangeEventDetail, SegmentValue } from "./components/segment/segment-interface"; export { SegmentButtonLayout } from "./components/segment-button/segment-button-interface"; +export { SegmentViewScrollEvent } from "./components/segment-view/segment-view-interface"; export { SelectChangeEventDetail, SelectCompareFn, SelectInterface } from "./components/select/select-interface"; export { SelectPopoverOption } from "./components/select-popover/select-popover-interface"; export { TabBarChangedEventDetail, TabButtonClickEventDetail, TabButtonLayout } from "./components/tab-bar/tab-bar-interface"; @@ -2717,10 +2719,6 @@ export namespace Components { "value": SegmentValue; } interface IonSegmentContent { - /** - * If `true`, the segment content will not be displayed. - */ - "disabled": boolean; } interface IonSegmentView { /** @@ -2728,7 +2726,6 @@ export namespace Components { */ "disabled": boolean; /** - * This method is used to programmatically set the displayed segment content in the segment view. Calling this method will update the `value` of the corresponding segment button. * @param id : The id of the segment content to display. * @param smoothScroll : Whether to animate the scroll transition. */ @@ -4442,12 +4439,8 @@ declare global { new (): HTMLIonSegmentContentElement; }; interface HTMLIonSegmentViewElementEventMap { - "ionSegmentViewScroll": { - scrollDirection: string; - scrollDistance: number; - scrollDistancePercentage: number; - }; - "ionSegmentViewScrollEnd": { activeContentId: string }; + "ionSegmentViewScroll": SegmentViewScrollEvent; + "ionSegmentViewScrollEnd": void; "ionSegmentViewScrollStart": void; } interface HTMLIonSegmentViewElement extends Components.IonSegmentView, HTMLStencilElement { @@ -7530,10 +7523,6 @@ declare namespace LocalJSX { "value"?: SegmentValue; } interface IonSegmentContent { - /** - * If `true`, the segment content will not be displayed. - */ - "disabled"?: boolean; } interface IonSegmentView { /** @@ -7543,15 +7532,11 @@ declare namespace LocalJSX { /** * Emitted when the segment view is scrolled. */ - "onIonSegmentViewScroll"?: (event: IonSegmentViewCustomEvent<{ - scrollDirection: string; - scrollDistance: number; - scrollDistancePercentage: number; - }>) => void; + "onIonSegmentViewScroll"?: (event: IonSegmentViewCustomEvent) => void; /** * Emitted when the segment view scroll has ended. */ - "onIonSegmentViewScrollEnd"?: (event: IonSegmentViewCustomEvent<{ activeContentId: string }>) => void; + "onIonSegmentViewScrollEnd"?: (event: IonSegmentViewCustomEvent) => void; "onIonSegmentViewScrollStart"?: (event: IonSegmentViewCustomEvent) => void; } interface IonSelect { diff --git a/core/src/components/segment-button/segment-button.tsx b/core/src/components/segment-button/segment-button.tsx index 004c853a7ea..80cb243c301 100644 --- a/core/src/components/segment-button/segment-button.tsx +++ b/core/src/components/segment-button/segment-button.tsx @@ -44,7 +44,7 @@ export class SegmentButton implements ComponentInterface, ButtonInterface { /** * If `true`, the user cannot interact with the segment button. */ - @Prop({ mutable: true }) disabled = false; + @Prop({ mutable: true, reflect: true }) disabled = false; /** * Set the layout of the text and icon in the segment. @@ -91,8 +91,11 @@ export class SegmentButton implements ComponentInterface, ButtonInterface { return; } - // Set the disabled state of the Segment Content based on the button's disabled state - segmentContent.disabled = this.disabled; + // Prevent buttons from being disabled when associated with segment content + if (this.disabled) { + console.warn(`Segment Button: Segment buttons cannot be disabled when associated with an .`); + this.disabled = false; + } } disconnectedCallback() { diff --git a/core/src/components/segment-content/segment-content.ios.scss b/core/src/components/segment-content/segment-content.ios.scss index 026e16b1f97..aee6789be18 100644 --- a/core/src/components/segment-content/segment-content.ios.scss +++ b/core/src/components/segment-content/segment-content.ios.scss @@ -3,7 +3,3 @@ // iOS Segment Content // -------------------------------------------------- - -:host(.segment-content-disabled) { - opacity: $segment-button-ios-opacity-disabled; -} diff --git a/core/src/components/segment-content/segment-content.md.scss b/core/src/components/segment-content/segment-content.md.scss index 1432941bc19..ea64ce72d8e 100644 --- a/core/src/components/segment-content/segment-content.md.scss +++ b/core/src/components/segment-content/segment-content.md.scss @@ -3,7 +3,3 @@ // Material Design Segment Content // -------------------------------------------------- - -:host(.segment-content-disabled) { - opacity: $segment-button-md-opacity-disabled; -} diff --git a/core/src/components/segment-content/segment-content.scss b/core/src/components/segment-content/segment-content.scss index 00ca64f3041..464402b41ff 100644 --- a/core/src/components/segment-content/segment-content.scss +++ b/core/src/components/segment-content/segment-content.scss @@ -3,6 +3,7 @@ :host { scroll-snap-align: center; + scroll-snap-stop: always; flex-shrink: 0; diff --git a/core/src/components/segment-content/segment-content.tsx b/core/src/components/segment-content/segment-content.tsx index a4db11101ee..3d17217f437 100644 --- a/core/src/components/segment-content/segment-content.tsx +++ b/core/src/components/segment-content/segment-content.tsx @@ -1,5 +1,5 @@ import type { ComponentInterface } from '@stencil/core'; -import { Component, Host, Prop, h } from '@stencil/core'; +import { Component, Host, h } from '@stencil/core'; @Component({ tag: 'ion-segment-content', @@ -10,20 +10,9 @@ import { Component, Host, Prop, h } from '@stencil/core'; shadow: true, }) export class SegmentContent implements ComponentInterface { - /** - * If `true`, the segment content will not be displayed. - */ - @Prop() disabled = false; - render() { - const { disabled } = this; - return ( - + ); diff --git a/core/src/components/segment-view/segment-view-interface.ts b/core/src/components/segment-view/segment-view-interface.ts new file mode 100644 index 00000000000..c6212152ed5 --- /dev/null +++ b/core/src/components/segment-view/segment-view-interface.ts @@ -0,0 +1,4 @@ +export interface SegmentViewScrollEvent { + scrollRatio: number; + isManualScroll: boolean; +} diff --git a/core/src/components/segment-view/segment-view.scss b/core/src/components/segment-view/segment-view.scss index e9eacc5a24f..a41030992f6 100644 --- a/core/src/components/segment-view/segment-view.scss +++ b/core/src/components/segment-view/segment-view.scss @@ -25,3 +25,7 @@ touch-action: none; overflow-x: hidden; } + +:host(.segment-view-scroll-disabled) { + pointer-events: none; +} diff --git a/core/src/components/segment-view/segment-view.tsx b/core/src/components/segment-view/segment-view.tsx index 892c6e1f280..c09b5797162 100644 --- a/core/src/components/segment-view/segment-view.tsx +++ b/core/src/components/segment-view/segment-view.tsx @@ -1,5 +1,7 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; -import { Component, Element, Event, Host, Listen, Method, Prop, h } from '@stencil/core'; +import { Component, Element, Event, Host, Listen, Method, Prop, State, h } from '@stencil/core'; + +import type { SegmentViewScrollEvent } from './segment-view-interface'; @Component({ tag: 'ion-segment-view', @@ -10,8 +12,6 @@ import { Component, Element, Event, Host, Listen, Method, Prop, h } from '@stenc shadow: true, }) export class SegmentView implements ComponentInterface { - private initialScrollLeft?: number; - private previousScrollLeft = 0; private scrollEndTimeout: ReturnType | null = null; private isTouching = false; @@ -22,65 +22,37 @@ export class SegmentView implements ComponentInterface { */ @Prop() disabled = false; + /** + * @internal + * + * If `true`, the segment view is scrollable. + * If `false`, pointer events will be disabled. This is to prevent issues with + * quickly scrolling after interacting with a segment button. + */ + @State() isManualScroll?: boolean; + /** * Emitted when the segment view is scrolled. */ - @Event() ionSegmentViewScroll!: EventEmitter<{ - scrollDirection: string; - scrollDistance: number; - scrollDistancePercentage: number; - }>; + @Event() ionSegmentViewScroll!: EventEmitter; /** * Emitted when the segment view scroll has ended. */ - @Event() ionSegmentViewScrollEnd!: EventEmitter<{ activeContentId: string }>; + @Event() ionSegmentViewScrollEnd!: EventEmitter; @Event() ionSegmentViewScrollStart!: EventEmitter; - private activeContentId = ''; - @Listen('scroll') handleScroll(ev: Event) { - const { initialScrollLeft, previousScrollLeft } = this; - const { scrollLeft, offsetWidth } = ev.target as HTMLElement; - - // Set initial scroll position if it's undefined - this.initialScrollLeft = initialScrollLeft ?? scrollLeft; - - // Determine the scroll direction based on the previous scroll position - const scrollDirection = scrollLeft > previousScrollLeft ? 'right' : 'left'; - this.previousScrollLeft = scrollLeft; + const { scrollLeft, scrollWidth, clientWidth } = ev.target as HTMLElement; + const scrollRatio = scrollLeft / (scrollWidth - clientWidth); - // Calculate the distance scrolled based on the initial scroll position - // and then transform it to a percentage of the segment view width - const scrollDistance = scrollLeft - this.initialScrollLeft; - const scrollDistancePercentage = Math.abs(scrollDistance) / offsetWidth; - - // Emit the scroll direction and distance this.ionSegmentViewScroll.emit({ - scrollDirection, - scrollDistance, - scrollDistancePercentage, + scrollRatio, + isManualScroll: this.isManualScroll ?? true, }); - // Check if the scroll is at a snapping point and return if not - const atSnappingPoint = scrollLeft % offsetWidth === 0; - if (!atSnappingPoint) return; - - // Find the current segment content based on the scroll position - const currentIndex = Math.round(scrollLeft / offsetWidth); - - // Recursively search for the next enabled content in the scroll direction - const segmentContent = this.getNextEnabledContent(currentIndex, scrollDirection); - - // Exit if no valid segment content found - if (!segmentContent) return; - - // Update active content ID and scroll to the segment content - this.activeContentId = segmentContent.id; - this.setContent(segmentContent.id); - // Reset the timeout to check for scroll end this.resetScrollEndTimeout(); } @@ -118,7 +90,7 @@ export class SegmentView implements ComponentInterface { } this.scrollEndTimeout = setTimeout(() => { this.checkForScrollEnd(); - }, 150); + }, 50); } /** @@ -127,20 +99,21 @@ export class SegmentView implements ComponentInterface { * reset the scroll position and emit the scroll end event. */ private checkForScrollEnd() { - const activeContent = this.getSegmentContents().find(content => content.id === this.activeContentId); - // Only emit scroll end event if the active content is not disabled and // the user is not touching the segment view - if (activeContent?.disabled === false && !this.isTouching) { - this.ionSegmentViewScrollEnd.emit({ activeContentId: this.activeContentId }); - this.initialScrollLeft = undefined; + if (!this.isTouching) { + this.ionSegmentViewScrollEnd.emit(); + this.isManualScroll = undefined; } } /** + * @internal + * * This method is used to programmatically set the displayed segment content * in the segment view. Calling this method will update the `value` of the * corresponding segment button. + * * @param id: The id of the segment content to display. * @param smoothScroll: Whether to animate the scroll transition. */ @@ -151,6 +124,9 @@ export class SegmentView implements ComponentInterface { if (index === -1) return; + this.isManualScroll = false; + this.resetScrollEndTimeout(); + const contentWidth = this.el.offsetWidth; this.el.scrollTo({ top: 0, @@ -163,35 +139,14 @@ export class SegmentView implements ComponentInterface { return Array.from(this.el.querySelectorAll('ion-segment-content')); } - /** - * Recursively find the next enabled segment content based on the scroll direction. - * If no enabled content is found, it will return null. - */ - private getNextEnabledContent(index: number, direction: string): HTMLIonSegmentContentElement | null { - const contents = this.getSegmentContents(); - - // Stop if we reach the beginning or end of the content array - if (index < 0 || index >= contents.length) return null; - - const segmentContent = contents[index]; - - // If the content is not disabled, return it - if (!segmentContent.disabled) { - return segmentContent; - } - - // Otherwise, keep searching in the same direction - const nextIndex = direction === 'right' ? index + 1 : index - 1; - return this.getNextEnabledContent(nextIndex, direction); - } - render() { - const { disabled } = this; + const { disabled, isManualScroll } = this; return ( diff --git a/core/src/components/segment-view/test/basic/index.html b/core/src/components/segment-view/test/basic/index.html index 0eefd5a5474..02321d28c4e 100644 --- a/core/src/components/segment-view/test/basic/index.html +++ b/core/src/components/segment-view/test/basic/index.html @@ -16,6 +16,8 @@ @@ -60,24 +62,11 @@ Value - - - All - - - Favorites - - - - All - Favorites - - Paid - + Free @@ -90,6 +79,47 @@ Top + + + Orange + + + Banana + + + Pear + + + Peach + + + Grape + + + Mango + + + Apple + + + Strawberry + + + Cherry + + + + Orange + Banana + Pear + Peach + Grape + Mango + Apple + Strawberry + Cherry + + @@ -114,7 +144,7 @@ currentValue = 'value'; } - segmentView.setContent(currentValue); + segment.value = currentValue; } async function clearSegmentValue() { diff --git a/core/src/components/segment-view/test/basic/segment-view.e2e.ts b/core/src/components/segment-view/test/basic/segment-view.e2e.ts index 5d7457743ad..1a7279487fd 100644 --- a/core/src/components/segment-view/test/basic/segment-view.e2e.ts +++ b/core/src/components/segment-view/test/basic/segment-view.e2e.ts @@ -89,4 +89,85 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { await expect(segmentContent).toBeInViewport(); }); }); + + test('should set correct segment button as checked when changing the value by scrolling the segment content', async ({ + page, + }) => { + await page.setContent( + ` + + + Paid + + + Free + + + Top + + + + + Free + Top + + `, + config + ); + + await page + .locator('ion-segment-view') + .evaluate( + (segmentView: HTMLIonSegmentViewElement) => !segmentView.classList.contains('segment-view-scroll-disabled') + ); + + await page.waitForChanges(); + + await page.locator('ion-segment-content[id="top"]').scrollIntoViewIfNeeded(); + + const segmentButton = page.locator('ion-segment-button[value="top"]'); + await expect(segmentButton).toHaveClass(/segment-button-checked/); + }); + + test('should set correct segment button as checked and show correct content when programmatically setting the segment vale', async ({ + page, + }) => { + await page.setContent( + ` + + + Paid + + + Free + + + Top + + + + + Free + Top + + `, + config + ); + + await page + .locator('ion-segment-view') + .evaluate( + (segmentView: HTMLIonSegmentViewElement) => !segmentView.classList.contains('segment-view-scroll-disabled') + ); + + await page.waitForChanges(); + + await page.locator('ion-segment').evaluate((segment: HTMLIonSegmentElement) => (segment.value = 'top')); + + const segmentButton = page.locator('ion-segment-button[value="top"]'); + await expect(segmentButton).toHaveClass(/segment-button-checked/); + + const segmentContent = page.locator('ion-segment-content[id="top"]'); + await expect(segmentContent).toBeInViewport(); + }); }); diff --git a/core/src/components/segment-view/test/disabled/index.html b/core/src/components/segment-view/test/disabled/index.html index dd30d3c38c0..d19722de6dc 100644 --- a/core/src/components/segment-view/test/disabled/index.html +++ b/core/src/components/segment-view/test/disabled/index.html @@ -64,11 +64,11 @@ Favorites - + Paid - + Free @@ -81,28 +81,7 @@ Top - - - a - - - b - - - c - - - d - - - - a - b - c - d - - - + Bookmarks diff --git a/core/src/components/segment-view/test/disabled/segment-view.e2e.ts b/core/src/components/segment-view/test/disabled/segment-view.e2e.ts index 6582b1e8c31..071e3604d48 100644 --- a/core/src/components/segment-view/test/disabled/segment-view.e2e.ts +++ b/core/src/components/segment-view/test/disabled/segment-view.e2e.ts @@ -7,28 +7,9 @@ import { configs, test } from '@utils/test/playwright'; configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => { test.describe(title('segment-view: disabled'), () => { test('should not have visual regressions', async ({ page }) => { - await page.setContent( - ` - - - - - Free - Top - - `, - config - ); + await page.goto('/src/components/segment-view/test/disabled', config); - const segment = page.locator('ion-segment-view'); - - await expect(segment).toHaveScreenshot(screenshot(`segment-view-disabled`)); + await expect(page).toHaveScreenshot(screenshot(`segment-view-disabled`)); }); }); }); @@ -38,36 +19,7 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => { */ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { test.describe(title('segment-view: disabled'), () => { - test('should show the second content when first segment content is disabled', async ({ page }) => { - await page.setContent( - ` - - - Paid - - - Free - - - Top - - - - - Free - Top - - `, - config - ); - - const segmentContent = page.locator('ion-segment-content[id="free"]'); - await expect(segmentContent).toBeInViewport(); - }); - - test('should scroll to the third content and update the segment value when the second segment content is disabled', async ({ - page, - }) => { + test('should keep button enabled even when disabled prop is set', async ({ page }) => { await page.setContent( ` @@ -82,20 +34,16 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { - - Free + + Free Top `, config ); - const segmentContent = page.locator('ion-segment-content[id="top"]'); - await segmentContent.scrollIntoViewIfNeeded(); - await expect(segmentContent).toBeInViewport(); - - const segment = page.locator('ion-segment'); - expect(await segment.evaluate((el: HTMLIonSegmentElement) => el.value)).toBe('top'); + const segmentButton = page.locator('ion-segment-button[value="free"]'); + await expect(segmentButton).not.toHaveAttribute('disabled'); }); }); }); diff --git a/core/src/components/segment-view/test/disabled/segment-view.e2e.ts-snapshots/segment-view-disabled-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/segment-view/test/disabled/segment-view.e2e.ts-snapshots/segment-view-disabled-ios-ltr-Mobile-Chrome-linux.png index 09790806a7f..58fa6b0d0ab 100644 Binary files a/core/src/components/segment-view/test/disabled/segment-view.e2e.ts-snapshots/segment-view-disabled-ios-ltr-Mobile-Chrome-linux.png and b/core/src/components/segment-view/test/disabled/segment-view.e2e.ts-snapshots/segment-view-disabled-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/segment-view/test/disabled/segment-view.e2e.ts-snapshots/segment-view-disabled-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/segment-view/test/disabled/segment-view.e2e.ts-snapshots/segment-view-disabled-ios-ltr-Mobile-Firefox-linux.png index 4dcb813e3ae..6ad473ada44 100644 Binary files a/core/src/components/segment-view/test/disabled/segment-view.e2e.ts-snapshots/segment-view-disabled-ios-ltr-Mobile-Firefox-linux.png and b/core/src/components/segment-view/test/disabled/segment-view.e2e.ts-snapshots/segment-view-disabled-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/segment-view/test/disabled/segment-view.e2e.ts-snapshots/segment-view-disabled-ios-ltr-Mobile-Safari-linux.png b/core/src/components/segment-view/test/disabled/segment-view.e2e.ts-snapshots/segment-view-disabled-ios-ltr-Mobile-Safari-linux.png index 9574679268d..521946d9f1b 100644 Binary files a/core/src/components/segment-view/test/disabled/segment-view.e2e.ts-snapshots/segment-view-disabled-ios-ltr-Mobile-Safari-linux.png and b/core/src/components/segment-view/test/disabled/segment-view.e2e.ts-snapshots/segment-view-disabled-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/segment-view/test/disabled/segment-view.e2e.ts-snapshots/segment-view-disabled-md-ltr-Mobile-Chrome-linux.png b/core/src/components/segment-view/test/disabled/segment-view.e2e.ts-snapshots/segment-view-disabled-md-ltr-Mobile-Chrome-linux.png index ce5c69da743..e192269dd6f 100644 Binary files a/core/src/components/segment-view/test/disabled/segment-view.e2e.ts-snapshots/segment-view-disabled-md-ltr-Mobile-Chrome-linux.png and b/core/src/components/segment-view/test/disabled/segment-view.e2e.ts-snapshots/segment-view-disabled-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/segment-view/test/disabled/segment-view.e2e.ts-snapshots/segment-view-disabled-md-ltr-Mobile-Firefox-linux.png b/core/src/components/segment-view/test/disabled/segment-view.e2e.ts-snapshots/segment-view-disabled-md-ltr-Mobile-Firefox-linux.png index cd6be072b37..1d84031d3e4 100644 Binary files a/core/src/components/segment-view/test/disabled/segment-view.e2e.ts-snapshots/segment-view-disabled-md-ltr-Mobile-Firefox-linux.png and b/core/src/components/segment-view/test/disabled/segment-view.e2e.ts-snapshots/segment-view-disabled-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/segment-view/test/disabled/segment-view.e2e.ts-snapshots/segment-view-disabled-md-ltr-Mobile-Safari-linux.png b/core/src/components/segment-view/test/disabled/segment-view.e2e.ts-snapshots/segment-view-disabled-md-ltr-Mobile-Safari-linux.png index ae81335014f..697b8ebfe9e 100644 Binary files a/core/src/components/segment-view/test/disabled/segment-view.e2e.ts-snapshots/segment-view-disabled-md-ltr-Mobile-Safari-linux.png and b/core/src/components/segment-view/test/disabled/segment-view.e2e.ts-snapshots/segment-view-disabled-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/segment/segment.tsx b/core/src/components/segment/segment.tsx index 671e33b0d3f..f16e31c5be9 100644 --- a/core/src/components/segment/segment.tsx +++ b/core/src/components/segment/segment.tsx @@ -7,6 +7,7 @@ import { createColorClasses, hostContext } from '@utils/theme'; import { getIonMode } from '../../global/ionic-global'; import type { Color, StyleEventDetail } from '../../interface'; +import type { SegmentViewScrollEvent } from '../segment-view/segment-view-interface'; import type { SegmentChangeEventDetail, SegmentValue } from './segment-interface'; @@ -28,8 +29,14 @@ export class Segment implements ComponentInterface { private valueBeforeGesture?: SegmentValue; private segmentViewEl?: HTMLIonSegmentViewElement | null = null; - private scrolledIndicator?: HTMLDivElement | null = null; - private isScrolling = false; + private lastNextIndex?: number; + + /** + * Whether to update the segment view, if exists, when the value changes. + * This behavior is enabled by default, but is set false when scrolling content views + * since we don't want to "double scroll" the segment view. + */ + private triggerScrollOnValueChange?: boolean; @Element() el!: HTMLIonSegmentElement; @@ -83,6 +90,12 @@ export class Segment implements ComponentInterface { @Watch('value') protected valueChanged(value: SegmentValue | undefined, oldValue?: SegmentValue | undefined) { + // Force a value to exist if we're using a segment view + if (this.segmentViewEl && value === undefined) { + this.value = this.getButtons()[0].value; + return; + } + if (oldValue !== undefined && value !== undefined) { const buttons = this.getButtons(); const previous = buttons.find((button) => button.value === oldValue); @@ -91,10 +104,12 @@ export class Segment implements ComponentInterface { if (previous && current) { if (!this.segmentViewEl) { this.checkButton(previous, current); - } else { - this.setCheckedClasses(); + } else if (this.triggerScrollOnValueChange !== false) { + this.updateSegmentView(); } } + } else if (value !== undefined && oldValue === undefined && this.segmentViewEl) { + this.updateSegmentView(); } /** @@ -107,6 +122,8 @@ export class Segment implements ComponentInterface { if (!this.segmentViewEl) { this.scrollActiveButtonIntoView(); } + + this.triggerScrollOnValueChange = undefined; } /** @@ -140,9 +157,13 @@ export class Segment implements ComponentInterface { disabledChanged() { this.gestureChanged(); - const buttons = this.getButtons(); - for (const button of buttons) { - button.disabled = this.disabled; + if (!this.segmentViewEl) { + const buttons = this.getButtons(); + for (const button of buttons) { + button.disabled = this.disabled; + } + } else { + this.segmentViewEl.disabled = this.disabled; } } @@ -322,6 +343,8 @@ export class Segment implements ComponentInterface { // Remove the transform to slide the indicator back to the button clicked currentIndicator.style.setProperty('transform', ''); + + this.scrollActiveButtonIntoView(true); }); this.value = current.value; @@ -352,8 +375,10 @@ export class Segment implements ComponentInterface { } @Listen('ionSegmentViewScroll', { target: 'body' }) - handleSegmentViewScroll(ev: CustomEvent) { - if (!this.isScrolling) { + handleSegmentViewScroll(ev: CustomEvent) { + const { scrollRatio, isManualScroll } = ev.detail; + + if (!isManualScroll) { return; } @@ -366,120 +391,20 @@ export class Segment implements ComponentInterface { const buttons = this.getButtons(); // If no buttons are found or there is no value set then do nothing - if (!buttons.length || this.value === undefined) return; + if (!buttons.length) return; const index = buttons.findIndex((button) => button.value === this.value); const current = buttons[index]; - const indicatorEl = this.getIndicator(current); - this.scrolledIndicator = indicatorEl; - - const { scrollDistancePercentage, scrollDistance } = ev.detail; - - if (indicatorEl && !isNaN(scrollDistancePercentage)) { - indicatorEl.style.transition = 'transform 0.3s ease-out'; - - // Calculate the amount the indicator should move based on the scroll percentage - // and the width of the current button - const scrollAmount = scrollDistancePercentage * current.getBoundingClientRect().width; - const transformValue = scrollDistance < 0 ? -scrollAmount : scrollAmount; - - // Calculate total width of buttons to the left of the current button - const totalButtonWidthBefore = buttons - .slice(0, index) - .reduce((acc, button) => acc + button.getBoundingClientRect().width, 0); - - // Calculate total width of buttons to the right of the current button - const totalButtonWidthAfter = buttons - .slice(index + 1) - .reduce((acc, button) => acc + button.getBoundingClientRect().width, 0); - - // Set minTransform and maxTransform - const minTransform = -totalButtonWidthBefore; - const maxTransform = totalButtonWidthAfter; - - // Clamp the transform value to ensure it doesn't go out of bounds - const clampedTransform = Math.max(minTransform, Math.min(transformValue, maxTransform)); - - // Apply the clamped transform value to the indicator element - const transform = `translate3d(${clampedTransform}px, 0, 0)`; - indicatorEl.style.setProperty('transform', transform); - - // Scroll the buttons if the indicator is out of view - const indicatorX = indicatorEl.getBoundingClientRect().x; - const buttonWidth = current.getBoundingClientRect().width; - if (scrollDistance < 0 && indicatorX < 0) { - this.el.scrollBy({ - top: 0, - left: indicatorX, - behavior: 'instant', - }); - } else if (scrollDistance > 0 && indicatorX + buttonWidth > this.el.offsetWidth) { - this.el.scrollBy({ - top: 0, - left: indicatorX + buttonWidth - this.el.offsetWidth, - behavior: 'instant', - }); - } - } - } - } - @Listen('ionSegmentViewScrollStart', { target: 'body' }) - onScrollStart(ev: CustomEvent) { - const dispatchedFrom = ev.target as HTMLElement; - const segmentViewEl = this.segmentViewEl as EventTarget; - const segmentEl = this.el; + const nextIndex = Math.round(scrollRatio * (buttons.length - 1)); - if (ev.composedPath().includes(segmentViewEl) || dispatchedFrom?.contains(segmentEl)) { - this.isScrolling = true; - } - } - - @Listen('ionSegmentViewScrollEnd', { target: 'body' }) - onScrollEnd(ev: CustomEvent<{ activeContentId: string }>) { - const dispatchedFrom = ev.target as HTMLElement; - const segmentViewEl = this.segmentViewEl as EventTarget; - const segmentEl = this.el; + if (this.lastNextIndex === undefined || this.lastNextIndex !== nextIndex) { + this.lastNextIndex = nextIndex; + this.triggerScrollOnValueChange = false; - if (ev.composedPath().includes(segmentViewEl) || dispatchedFrom?.contains(segmentEl)) { - if (this.scrolledIndicator) { - const computedStyle = window.getComputedStyle(this.scrolledIndicator); - const isTransitioning = computedStyle.transitionDuration !== '0s'; - - if (isTransitioning) { - // Add a transitionend listener if the indicator is transitioning - this.waitForTransitionEnd(this.scrolledIndicator, () => { - this.updateValueAfterTransition(ev.detail.activeContentId); - }); - } else { - // Immediately update the value if there's no transition - this.updateValueAfterTransition(ev.detail.activeContentId); - } - } else { - // Immediately update the value if there's no indicator - this.updateValueAfterTransition(ev.detail.activeContentId); + this.checkButton(current, buttons[nextIndex]); + this.emitValueChange(); } - - this.isScrolling = false; - } - } - - // Wait for the transition to end, then execute the callback - private waitForTransitionEnd(indicator: HTMLElement, callback: () => void) { - const onTransitionEnd = () => { - indicator.removeEventListener('transitionend', onTransitionEnd); - callback(); - }; - indicator.addEventListener('transitionend', onTransitionEnd); - } - - // Update the Segment value after the ionSegmentViewScrollEnd transition has ended - private updateValueAfterTransition(activeContentId: string) { - this.value = activeContentId; - - if (this.scrolledIndicator) { - this.scrolledIndicator.style.transition = ''; - this.scrolledIndicator.style.transform = ''; } } @@ -500,8 +425,7 @@ export class Segment implements ComponentInterface { return; } - const content = document.getElementById(button.contentId); - const segmentView = content?.closest('ion-segment-view'); + const segmentView = this.segmentViewEl; if (segmentView) { segmentView.setContent(button.contentId, smoothScroll); @@ -672,10 +596,15 @@ export class Segment implements ComponentInterface { if (current !== previous) { this.emitValueChange(); - this.updateSegmentView(); } - if (this.scrollable || !this.swipeGesture) { + if (this.segmentViewEl) { + this.updateSegmentView(); + + if (this.scrollable && previous) { + this.checkButton(previous, current); + } + } else if (this.scrollable || !this.swipeGesture) { if (previous) { this.checkButton(previous, current); } else { diff --git a/packages/angular/src/directives/proxies.ts b/packages/angular/src/directives/proxies.ts index 6e8e6303b13..9770c3bc456 100644 --- a/packages/angular/src/directives/proxies.ts +++ b/packages/angular/src/directives/proxies.ts @@ -2006,14 +2006,13 @@ export declare interface IonSegmentButton extends Components.IonSegmentButton {} @ProxyCmp({ - inputs: ['disabled'] }) @Component({ selector: 'ion-segment-content', changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['disabled'], + inputs: [], }) export class IonSegmentContent { protected el: HTMLElement; @@ -2028,8 +2027,7 @@ export declare interface IonSegmentContent extends Components.IonSegmentContent @ProxyCmp({ - inputs: ['disabled'], - methods: ['setContent'] + inputs: ['disabled'] }) @Component({ selector: 'ion-segment-view', @@ -2048,15 +2046,17 @@ export class IonSegmentView { } +import type { SegmentViewScrollEvent as IIonSegmentViewSegmentViewScrollEvent } from '@ionic/core'; + export declare interface IonSegmentView extends Components.IonSegmentView { /** * Emitted when the segment view is scrolled. */ - ionSegmentViewScroll: EventEmitter>; + ionSegmentViewScroll: EventEmitter>; /** * Emitted when the segment view scroll has ended. */ - ionSegmentViewScrollEnd: EventEmitter>; + ionSegmentViewScrollEnd: EventEmitter>; ionSegmentViewScrollStart: EventEmitter>; } diff --git a/packages/angular/standalone/src/directives/proxies.ts b/packages/angular/standalone/src/directives/proxies.ts index 35be08d93e2..932ded496af 100644 --- a/packages/angular/standalone/src/directives/proxies.ts +++ b/packages/angular/standalone/src/directives/proxies.ts @@ -1841,15 +1841,14 @@ export declare interface IonSegmentButton extends Components.IonSegmentButton {} @ProxyCmp({ - defineCustomElementFn: defineIonSegmentContent, - inputs: ['disabled'] + defineCustomElementFn: defineIonSegmentContent }) @Component({ selector: 'ion-segment-content', changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['disabled'], + inputs: [], standalone: true }) export class IonSegmentContent { @@ -1866,8 +1865,7 @@ export declare interface IonSegmentContent extends Components.IonSegmentContent @ProxyCmp({ defineCustomElementFn: defineIonSegmentView, - inputs: ['disabled'], - methods: ['setContent'] + inputs: ['disabled'] }) @Component({ selector: 'ion-segment-view', @@ -1887,15 +1885,17 @@ export class IonSegmentView { } +import type { SegmentViewScrollEvent as IIonSegmentViewSegmentViewScrollEvent } from '@ionic/core/components'; + export declare interface IonSegmentView extends Components.IonSegmentView { /** * Emitted when the segment view is scrolled. */ - ionSegmentViewScroll: EventEmitter>; + ionSegmentViewScroll: EventEmitter>; /** * Emitted when the segment view scroll has ended. */ - ionSegmentViewScrollEnd: EventEmitter>; + ionSegmentViewScrollEnd: EventEmitter>; ionSegmentViewScrollStart: EventEmitter>; } diff --git a/packages/vue/src/proxies.ts b/packages/vue/src/proxies.ts index d72ec0378c5..c83e7b650e9 100644 --- a/packages/vue/src/proxies.ts +++ b/packages/vue/src/proxies.ts @@ -755,9 +755,7 @@ export const IonSegmentButton = /*@__PURE__*/ defineContainer('ion-segment-content', defineIonSegmentContent, [ - 'disabled' -]); +export const IonSegmentContent = /*@__PURE__*/ defineContainer('ion-segment-content', defineIonSegmentContent); export const IonSegmentView = /*@__PURE__*/ defineContainer('ion-segment-view', defineIonSegmentView, [