diff --git a/core/api.txt b/core/api.txt index 3aff52a1cfc..67d49417556 100644 --- a/core/api.txt +++ b/core/api.txt @@ -1542,6 +1542,7 @@ ion-segment,css-prop,--background,ios 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,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 @@ -1607,6 +1608,12 @@ ion-segment-button,part,indicator ion-segment-button,part,indicator-background ion-segment-button,part,native +ion-segment-content,shadow + +ion-segment-view,shadow +ion-segment-view,prop,disabled,boolean,false,false,false +ion-segment-view,event,ionSegmentViewScroll,SegmentViewScrollEvent,true + ion-select,shadow ion-select,prop,cancelText,string,'Cancel',false,false ion-select,prop,color,"danger" | "dark" | "light" | "medium" | "primary" | "secondary" | "success" | "tertiary" | "warning" | string & Record | undefined,undefined,false,true diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 48ceedfa8a1..2e5163329dd 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 { SelectModalOption } from "./components/select-modal/select-modal-interface"; import { SelectPopoverOption } from "./components/select-popover/select-popover-interface"; @@ -70,6 +71,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 { SelectModalOption } from "./components/select-modal/select-modal-interface"; export { SelectPopoverOption } from "./components/select-popover/select-popover-interface"; @@ -2696,6 +2698,10 @@ export namespace Components { "value"?: SegmentValue; } interface IonSegmentButton { + /** + * The `id` of the segment content. + */ + "contentId"?: string; /** * If `true`, the user cannot interact with the segment button. */ @@ -2718,6 +2724,19 @@ export namespace Components { */ "value": SegmentValue; } + interface IonSegmentContent { + } + interface IonSegmentView { + /** + * If `true`, the segment view cannot be interacted with. + */ + "disabled": boolean; + /** + * @param id : The id of the segment content to display. + * @param smoothScroll : Whether to animate the scroll transition. + */ + "setContent": (id: string, smoothScroll?: boolean) => Promise; + } interface IonSelect { /** * The text to display on the cancel button. @@ -3424,6 +3443,10 @@ export interface IonSegmentCustomEvent extends CustomEvent { detail: T; target: HTMLIonSegmentElement; } +export interface IonSegmentViewCustomEvent extends CustomEvent { + detail: T; + target: HTMLIonSegmentViewElement; +} export interface IonSelectCustomEvent extends CustomEvent { detail: T; target: HTMLIonSelectElement; @@ -4420,6 +4443,29 @@ declare global { prototype: HTMLIonSegmentButtonElement; new (): HTMLIonSegmentButtonElement; }; + interface HTMLIonSegmentContentElement extends Components.IonSegmentContent, HTMLStencilElement { + } + var HTMLIonSegmentContentElement: { + prototype: HTMLIonSegmentContentElement; + new (): HTMLIonSegmentContentElement; + }; + interface HTMLIonSegmentViewElementEventMap { + "ionSegmentViewScroll": SegmentViewScrollEvent; + } + interface HTMLIonSegmentViewElement extends Components.IonSegmentView, HTMLStencilElement { + addEventListener(type: K, listener: (this: HTMLIonSegmentViewElement, ev: IonSegmentViewCustomEvent) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void; + removeEventListener(type: K, listener: (this: HTMLIonSegmentViewElement, ev: IonSegmentViewCustomEvent) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void; + } + var HTMLIonSegmentViewElement: { + prototype: HTMLIonSegmentViewElement; + new (): HTMLIonSegmentViewElement; + }; interface HTMLIonSelectElementEventMap { "ionChange": SelectChangeEventDetail; "ionCancel": void; @@ -4735,6 +4781,8 @@ declare global { "ion-searchbar": HTMLIonSearchbarElement; "ion-segment": HTMLIonSegmentElement; "ion-segment-button": HTMLIonSegmentButtonElement; + "ion-segment-content": HTMLIonSegmentContentElement; + "ion-segment-view": HTMLIonSegmentViewElement; "ion-select": HTMLIonSelectElement; "ion-select-modal": HTMLIonSelectModalElement; "ion-select-option": HTMLIonSelectOptionElement; @@ -7465,6 +7513,10 @@ declare namespace LocalJSX { "value"?: SegmentValue; } interface IonSegmentButton { + /** + * The `id` of the segment content. + */ + "contentId"?: string; /** * If `true`, the user cannot interact with the segment button. */ @@ -7486,6 +7538,18 @@ declare namespace LocalJSX { */ "value"?: SegmentValue; } + interface IonSegmentContent { + } + interface IonSegmentView { + /** + * If `true`, the segment view cannot be interacted with. + */ + "disabled"?: boolean; + /** + * Emitted when the segment view is scrolled. + */ + "onIonSegmentViewScroll"?: (event: IonSegmentViewCustomEvent) => void; + } interface IonSelect { /** * The text to display on the cancel button. @@ -8182,6 +8246,8 @@ declare namespace LocalJSX { "ion-searchbar": IonSearchbar; "ion-segment": IonSegment; "ion-segment-button": IonSegmentButton; + "ion-segment-content": IonSegmentContent; + "ion-segment-view": IonSegmentView; "ion-select": IonSelect; "ion-select-modal": IonSelectModal; "ion-select-option": IonSelectOption; @@ -8282,6 +8348,8 @@ declare module "@stencil/core" { "ion-searchbar": LocalJSX.IonSearchbar & JSXBase.HTMLAttributes; "ion-segment": LocalJSX.IonSegment & JSXBase.HTMLAttributes; "ion-segment-button": LocalJSX.IonSegmentButton & JSXBase.HTMLAttributes; + "ion-segment-content": LocalJSX.IonSegmentContent & JSXBase.HTMLAttributes; + "ion-segment-view": LocalJSX.IonSegmentView & JSXBase.HTMLAttributes; "ion-select": LocalJSX.IonSelect & JSXBase.HTMLAttributes; "ion-select-modal": LocalJSX.IonSelectModal & JSXBase.HTMLAttributes; "ion-select-option": LocalJSX.IonSelectOption & JSXBase.HTMLAttributes; diff --git a/core/src/components/segment-button/segment-button.tsx b/core/src/components/segment-button/segment-button.tsx index 26954fb6cbd..d860735f729 100644 --- a/core/src/components/segment-button/segment-button.tsx +++ b/core/src/components/segment-button/segment-button.tsx @@ -36,6 +36,11 @@ export class SegmentButton implements ComponentInterface, ButtonInterface { @State() checked = false; + /** + * The `id` of the segment content. + */ + @Prop({ reflect: true }) contentId?: string; + /** * If `true`, the user cannot interact with the segment button. */ @@ -67,6 +72,30 @@ export class SegmentButton implements ComponentInterface, ButtonInterface { addEventListener(segmentEl, 'ionSelect', this.updateState); addEventListener(segmentEl, 'ionStyle', this.updateStyle); } + + // Return if there is no contentId defined + if (!this.contentId) return; + + // Attempt to find the Segment Content by its contentId + const segmentContent = document.getElementById(this.contentId) as HTMLIonSegmentContentElement | null; + + // If no associated Segment Content exists, log an error and return + if (!segmentContent) { + console.error(`Segment Button: Unable to find Segment Content with id="${this.contentId}".`); + return; + } + + // Ensure the found element is a valid ION-SEGMENT-CONTENT + if (segmentContent.tagName !== 'ION-SEGMENT-CONTENT') { + console.error(`Segment Button: Element with id="${this.contentId}" is not an element.`); + return; + } + + // 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() { @@ -161,13 +190,7 @@ export class SegmentButton implements ComponentInterface, ButtonInterface { {mode === 'md' && } -
+
diff --git a/core/src/components/segment-content/segment-content.scss b/core/src/components/segment-content/segment-content.scss new file mode 100644 index 00000000000..464402b41ff --- /dev/null +++ b/core/src/components/segment-content/segment-content.scss @@ -0,0 +1,11 @@ +// Segment Content +// -------------------------------------------------- + +:host { + scroll-snap-align: center; + scroll-snap-stop: always; + + flex-shrink: 0; + + width: 100%; +} diff --git a/core/src/components/segment-content/segment-content.tsx b/core/src/components/segment-content/segment-content.tsx new file mode 100644 index 00000000000..2b5fefebff2 --- /dev/null +++ b/core/src/components/segment-content/segment-content.tsx @@ -0,0 +1,17 @@ +import type { ComponentInterface } from '@stencil/core'; +import { Component, Host, h } from '@stencil/core'; + +@Component({ + tag: 'ion-segment-content', + styleUrl: 'segment-content.scss', + shadow: true, +}) +export class SegmentContent implements ComponentInterface { + render() { + 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.ios.scss b/core/src/components/segment-view/segment-view.ios.scss new file mode 100644 index 00000000000..a8c51304565 --- /dev/null +++ b/core/src/components/segment-view/segment-view.ios.scss @@ -0,0 +1,9 @@ +@import "./segment-view"; +@import "../segment-button/segment-button.ios.vars"; + +// iOS Segment View +// -------------------------------------------------- + +:host(.segment-view-disabled) { + opacity: $segment-button-ios-opacity-disabled; +} diff --git a/core/src/components/segment-view/segment-view.md.scss b/core/src/components/segment-view/segment-view.md.scss new file mode 100644 index 00000000000..4a141cf5c89 --- /dev/null +++ b/core/src/components/segment-view/segment-view.md.scss @@ -0,0 +1,9 @@ +@import "./segment-view"; +@import "../segment-button/segment-button.md.vars"; + +// Material Design Segment View +// -------------------------------------------------- + +:host(.segment-view-disabled) { + opacity: $segment-button-md-opacity-disabled; +} diff --git a/core/src/components/segment-view/segment-view.scss b/core/src/components/segment-view/segment-view.scss new file mode 100644 index 00000000000..a41030992f6 --- /dev/null +++ b/core/src/components/segment-view/segment-view.scss @@ -0,0 +1,31 @@ +// Segment View +// -------------------------------------------------- + +:host { + display: flex; + + height: 100%; + + overflow-x: scroll; + scroll-snap-type: x mandatory; + + /* Hide scrollbar in Firefox */ + scrollbar-width: none; + + /* Hide scrollbar in IE and Edge */ + -ms-overflow-style: none; +} + +/* Hide scrollbar in webkit */ +:host::-webkit-scrollbar { + display: none; +} + +:host(.segment-view-disabled) { + 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 new file mode 100644 index 00000000000..633d9a37d39 --- /dev/null +++ b/core/src/components/segment-view/segment-view.tsx @@ -0,0 +1,153 @@ +import type { ComponentInterface, EventEmitter } 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', + styleUrls: { + ios: 'segment-view.ios.scss', + md: 'segment-view.md.scss', + }, + shadow: true, +}) +export class SegmentView implements ComponentInterface { + private scrollEndTimeout: ReturnType | null = null; + private isTouching = false; + + @Element() el!: HTMLElement; + + /** + * If `true`, the segment view cannot be interacted with. + */ + @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; + + @Listen('scroll') + handleScroll(ev: Event) { + const { scrollLeft, scrollWidth, clientWidth } = ev.target as HTMLElement; + const scrollRatio = scrollLeft / (scrollWidth - clientWidth); + + this.ionSegmentViewScroll.emit({ + scrollRatio, + isManualScroll: this.isManualScroll ?? true, + }); + + // Reset the timeout to check for scroll end + this.resetScrollEndTimeout(); + } + + /** + * Handle touch start event to know when the user is actively dragging the segment view. + */ + @Listen('touchstart') + handleScrollStart() { + if (this.scrollEndTimeout) { + clearTimeout(this.scrollEndTimeout); + this.scrollEndTimeout = null; + } + + this.isTouching = true; + } + + /** + * Handle touch end event to know when the user is no longer dragging the segment view. + */ + @Listen('touchend') + handleTouchEnd() { + this.isTouching = false; + } + + /** + * Reset the scroll end detection timer. This is called on every scroll event. + */ + private resetScrollEndTimeout() { + if (this.scrollEndTimeout) { + clearTimeout(this.scrollEndTimeout); + this.scrollEndTimeout = null; + } + this.scrollEndTimeout = setTimeout( + () => { + this.checkForScrollEnd(); + }, + // Setting this to a lower value may result in inconsistencies in behavior + // across browsers (particularly Firefox). + // Ideally, all of this logic is removed once the scroll end event is + // supported on all browsers (https://caniuse.com/?search=scrollend) + 100 + ); + } + + /** + * Check if the scroll has ended and the user is not actively touching. + * If the conditions are met (active content is enabled and no active touch), + * reset the scroll position and emit the scroll end event. + */ + private checkForScrollEnd() { + // Only emit scroll end event if the active content is not disabled and + // the user is not touching the segment view + if (!this.isTouching) { + 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. + */ + @Method() + async setContent(id: string, smoothScroll = true) { + const contents = this.getSegmentContents(); + const index = contents.findIndex((content) => content.id === id); + + if (index === -1) return; + + this.isManualScroll = false; + this.resetScrollEndTimeout(); + + const contentWidth = this.el.offsetWidth; + this.el.scrollTo({ + top: 0, + left: index * contentWidth, + behavior: smoothScroll ? 'smooth' : 'instant', + }); + } + + private getSegmentContents(): HTMLIonSegmentContentElement[] { + return Array.from(this.el.querySelectorAll('ion-segment-content')); + } + + render() { + 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 new file mode 100644 index 00000000000..69d36d4a6c0 --- /dev/null +++ b/core/src/components/segment-view/test/basic/index.html @@ -0,0 +1,164 @@ + + + + + Segment View - Basic + + + + + + + + + + + + + + + Segment View - Basic + + + + + + + No + + + Value + + + + No + Value + + + + + Paid + + + Free + + + Top + + + + + Free + Top + + + + + Orange + + + Banana + + + Pear + + + Peach + + + Grape + + + Mango + + + Apple + + + Strawberry + + + Cherry + + + + Orange + Banana + Pear + Peach + Grape + Mango + Apple + Strawberry + Cherry + + + + + + + + + + Footer + + + + + + + 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 new file mode 100644 index 00000000000..447a60be309 --- /dev/null +++ b/core/src/components/segment-view/test/basic/segment-view.e2e.ts @@ -0,0 +1,173 @@ +import { expect } from '@playwright/test'; +import { configs, test } from '@utils/test/playwright'; + +/** + * This behavior does not vary across modes/directions + */ +configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('segment-view: basic'), () => { + test('should show the first content with no initial value', async ({ page }) => { + await page.setContent( + ` + + + Paid + + + Free + + + Top + + + + + Free + Top + + `, + config + ); + + const segmentContent = page.locator('ion-segment-content[id="paid"]'); + await expect(segmentContent).toBeInViewport(); + }); + + test('should show the content matching the initial value', 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 update the content when changing the value by clicking a segment button', async ({ page }) => { + await page.setContent( + ` + + + Paid + + + Free + + + Top + + + + + Free + Top + + `, + config + ); + + await page.locator('ion-segment-button[value="top"]').click(); + + const segmentContent = page.locator('ion-segment-content[id="top"]'); + 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 new file mode 100644 index 00000000000..d19722de6dc --- /dev/null +++ b/core/src/components/segment-view/test/disabled/index.html @@ -0,0 +1,103 @@ + + + + + Segment View - Disabled + + + + + + + + + + + + + + + Segment View - Disabled + + + + + + + All + + + Favorites + + + + All + Favorites + + + + + Paid + + + Free + + + Top + + + + + Free + Top + + + + + Bookmarks + + + Reading List + + + Shared Links + + + + Bookmarks + Reading List + Shared Links + + + + + 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 new file mode 100644 index 00000000000..c7dead8943f --- /dev/null +++ b/core/src/components/segment-view/test/disabled/segment-view.e2e.ts @@ -0,0 +1,49 @@ +import { expect } from '@playwright/test'; +import { configs, test } from '@utils/test/playwright'; + +/** + * This behavior does not vary across directions + */ +configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => { + test.describe(title('segment-view: disabled'), () => { + test('should not have visual regressions', async ({ page }) => { + await page.goto('/src/components/segment-view/test/disabled', config); + + await expect(page).toHaveScreenshot(screenshot(`segment-view-disabled`)); + }); + }); +}); + +/** + * This behavior does not vary across modes/directions + */ +configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('segment-view: disabled'), () => { + test('should keep button enabled even when disabled prop is set', async ({ page }) => { + await page.setContent( + ` + + + Paid + + + Free + + + Top + + + + + Free + Top + + `, + config + ); + + const segmentButton = page.locator('ion-segment-button[value="free"]'); + await expect(segmentButton).not.toHaveClass(/segment-button-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 new file mode 100644 index 00000000000..58fa6b0d0ab Binary files /dev/null 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 new file mode 100644 index 00000000000..6ad473ada44 Binary files /dev/null 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 new file mode 100644 index 00000000000..521946d9f1b Binary files /dev/null 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 new file mode 100644 index 00000000000..e192269dd6f Binary files /dev/null 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 new file mode 100644 index 00000000000..1d84031d3e4 Binary files /dev/null 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 new file mode 100644 index 00000000000..697b8ebfe9e Binary files /dev/null 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 3ddf8645896..074c2586b87 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'; @@ -27,6 +28,16 @@ export class Segment implements ComponentInterface { // Value before the segment is dragged private valueBeforeGesture?: SegmentValue; + private segmentViewEl?: HTMLIonSegmentViewElement | null = null; + 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; @State() activated = false; @@ -78,13 +89,41 @@ export class Segment implements ComponentInterface { @Prop({ mutable: true }) value?: SegmentValue; @Watch('value') - protected valueChanged(value: SegmentValue | undefined) { + 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); + const current = buttons.find((button) => button.value === value); + + if (previous && current) { + if (!this.segmentViewEl) { + this.checkButton(previous, current); + } else if (this.triggerScrollOnValueChange !== false) { + this.updateSegmentView(); + } + } + } else if (value !== undefined && oldValue === undefined && this.segmentViewEl) { + this.updateSegmentView(); + } + /** * `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 }); - this.scrollActiveButtonIntoView(); + + // The scroll listener should handle scrolling the active button into view as needed + if (!this.segmentViewEl) { + this.scrollActiveButtonIntoView(); + } + + this.triggerScrollOnValueChange = undefined; } /** @@ -118,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; } } @@ -132,6 +175,12 @@ export class Segment implements ComponentInterface { connectedCallback() { this.emitStyle(); + + this.segmentViewEl = this.getSegmentView(); + } + + disconnectedCallback() { + this.segmentViewEl = null; } componentWillLoad() { @@ -170,6 +219,10 @@ export class Segment implements ComponentInterface { if (this.disabled) { this.disabledChanged(); } + + // Update segment view based on the initial value, + // but do not animate the scroll + this.updateSegmentView(false); } onStart(detail: GestureDetail) { @@ -192,6 +245,7 @@ export class Segment implements ComponentInterface { if (value !== undefined) { if (this.valueBeforeGesture !== value) { this.emitValueChange(); + this.updateSegmentView(); } } this.valueBeforeGesture = undefined; @@ -208,7 +262,7 @@ export class Segment implements ComponentInterface { this.ionChange.emit({ value }); } - private getButtons() { + private getButtons(): HTMLIonSegmentButtonElement[] { return Array.from(this.el.querySelectorAll('ion-segment-button')); } @@ -224,11 +278,7 @@ export class Segment implements ComponentInterface { const buttons = this.getButtons(); buttons.forEach((button) => { - if (activated) { - button.classList.add('segment-button-activated'); - } else { - button.classList.remove('segment-button-activated'); - } + button.classList.toggle('segment-button-activated', activated); }); this.activated = activated; } @@ -293,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; @@ -312,6 +364,74 @@ export class Segment implements ComponentInterface { } } + private getSegmentView() { + const buttons = this.getButtons(); + // Get the first button with a contentId + const firstContentId = buttons.find((button: HTMLIonSegmentButtonElement) => button.contentId); + // Get the segment content with an id matching the button's contentId + const segmentContent = document.querySelector(`ion-segment-content[id="${firstContentId?.contentId}"]`); + // Return the segment view for that matching segment content + return segmentContent?.closest('ion-segment-view'); + } + + @Listen('ionSegmentViewScroll', { target: 'body' }) + handleSegmentViewScroll(ev: CustomEvent) { + const { scrollRatio, isManualScroll } = ev.detail; + + if (!isManualScroll) { + return; + } + + const dispatchedFrom = ev.target as HTMLElement; + const segmentViewEl = this.segmentViewEl as EventTarget; + const segmentEl = this.el; + + // Only update the indicator if the event was dispatched from the correct segment view + if (ev.composedPath().includes(segmentViewEl) || dispatchedFrom?.contains(segmentEl)) { + const buttons = this.getButtons(); + + // If no buttons are found or there is no value set then do nothing + if (!buttons.length) return; + + const index = buttons.findIndex((button) => button.value === this.value); + const current = buttons[index]; + + const nextIndex = Math.round(scrollRatio * (buttons.length - 1)); + + if (this.lastNextIndex === undefined || this.lastNextIndex !== nextIndex) { + this.lastNextIndex = nextIndex; + this.triggerScrollOnValueChange = false; + + this.checkButton(current, buttons[nextIndex]); + this.emitValueChange(); + } + } + } + + /** + * Finds the related segment view and sets its current content + * based on the selected segment button. This method + * should be called on initial load of the segment, + * after the gesture is completed (if dragging between segments) + * and when a segment button is clicked directly. + */ + private updateSegmentView(smoothScroll = true) { + const buttons = this.getButtons(); + const button = buttons.find((btn) => btn.value === this.value); + + // If the button does not have a contentId then there is + // no associated segment view to update + if (!button?.contentId) { + return; + } + + const segmentView = this.segmentViewEl; + + if (segmentView) { + segmentView.setContent(button.contentId, smoothScroll); + } + } + private scrollActiveButtonIntoView(smoothScroll = true) { const { scrollable, value, el } = this; @@ -492,7 +612,13 @@ export class Segment implements ComponentInterface { this.emitValueChange(); } - 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-list.ts b/packages/angular/src/directives/proxies-list.ts index 93d0cc13ad9..b61d09669ca 100644 --- a/packages/angular/src/directives/proxies-list.ts +++ b/packages/angular/src/directives/proxies-list.ts @@ -69,6 +69,8 @@ export const DIRECTIVES = [ d.IonSearchbar, d.IonSegment, d.IonSegmentButton, + d.IonSegmentContent, + d.IonSegmentView, d.IonSelect, d.IonSelectModal, d.IonSelectOption, diff --git a/packages/angular/src/directives/proxies.ts b/packages/angular/src/directives/proxies.ts index e384f56c531..675c37bd1c1 100644 --- a/packages/angular/src/directives/proxies.ts +++ b/packages/angular/src/directives/proxies.ts @@ -1987,14 +1987,14 @@ This event will not emit when programmatically setting the `value` property. @ProxyCmp({ - inputs: ['disabled', 'layout', 'mode', 'type', 'value'] + inputs: ['contentId', 'disabled', 'layout', 'mode', 'type', 'value'] }) @Component({ selector: 'ion-segment-button', changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['disabled', 'layout', 'mode', 'type', 'value'], + inputs: ['contentId', 'disabled', 'layout', 'mode', 'type', 'value'], }) export class IonSegmentButton { protected el: HTMLElement; @@ -2008,6 +2008,57 @@ export class IonSegmentButton { export declare interface IonSegmentButton extends Components.IonSegmentButton {} +@ProxyCmp({ +}) +@Component({ + selector: 'ion-segment-content', + changeDetection: ChangeDetectionStrategy.OnPush, + template: '', + // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property + inputs: [], +}) +export class IonSegmentContent { + protected el: HTMLElement; + constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) { + c.detach(); + this.el = r.nativeElement; + } +} + + +export declare interface IonSegmentContent extends Components.IonSegmentContent {} + + +@ProxyCmp({ + inputs: ['disabled'] +}) +@Component({ + selector: 'ion-segment-view', + changeDetection: ChangeDetectionStrategy.OnPush, + template: '', + // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property + inputs: ['disabled'], +}) +export class IonSegmentView { + protected el: HTMLElement; + constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) { + c.detach(); + this.el = r.nativeElement; + proxyOutputs(this, this.el, ['ionSegmentViewScroll']); + } +} + + +import type { SegmentViewScrollEvent as IIonSegmentViewSegmentViewScrollEvent } from '@ionic/core'; + +export declare interface IonSegmentView extends Components.IonSegmentView { + /** + * Emitted when the segment view is scrolled. + */ + ionSegmentViewScroll: EventEmitter>; +} + + @ProxyCmp({ inputs: ['cancelText', 'color', 'compareWith', 'disabled', 'expandedIcon', 'fill', 'interface', 'interfaceOptions', 'justify', 'label', 'labelPlacement', 'mode', 'multiple', 'name', 'okText', 'placeholder', 'selectedText', 'shape', 'toggleIcon', 'value'], methods: ['open'] diff --git a/packages/angular/standalone/src/directives/proxies.ts b/packages/angular/standalone/src/directives/proxies.ts index 5fac940a661..a30385ead5c 100644 --- a/packages/angular/standalone/src/directives/proxies.ts +++ b/packages/angular/standalone/src/directives/proxies.ts @@ -65,6 +65,8 @@ import { defineCustomElement as defineIonReorderGroup } from '@ionic/core/compon import { defineCustomElement as defineIonRippleEffect } from '@ionic/core/components/ion-ripple-effect.js'; import { defineCustomElement as defineIonRow } from '@ionic/core/components/ion-row.js'; import { defineCustomElement as defineIonSegmentButton } from '@ionic/core/components/ion-segment-button.js'; +import { defineCustomElement as defineIonSegmentContent } from '@ionic/core/components/ion-segment-content.js'; +import { defineCustomElement as defineIonSegmentView } from '@ionic/core/components/ion-segment-view.js'; import { defineCustomElement as defineIonSelectModal } from '@ionic/core/components/ion-select-modal.js'; import { defineCustomElement as defineIonSelectOption } from '@ionic/core/components/ion-select-option.js'; import { defineCustomElement as defineIonSkeletonText } from '@ionic/core/components/ion-skeleton-text.js'; @@ -1820,14 +1822,14 @@ export declare interface IonRow extends Components.IonRow {} @ProxyCmp({ defineCustomElementFn: defineIonSegmentButton, - inputs: ['disabled', 'layout', 'mode', 'type', 'value'] + inputs: ['contentId', 'disabled', 'layout', 'mode', 'type', 'value'] }) @Component({ selector: 'ion-segment-button', changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['disabled', 'layout', 'mode', 'type', 'value'], + inputs: ['contentId', 'disabled', 'layout', 'mode', 'type', 'value'], standalone: true }) export class IonSegmentButton { @@ -1842,6 +1844,61 @@ export class IonSegmentButton { export declare interface IonSegmentButton extends Components.IonSegmentButton {} +@ProxyCmp({ + defineCustomElementFn: defineIonSegmentContent +}) +@Component({ + selector: 'ion-segment-content', + changeDetection: ChangeDetectionStrategy.OnPush, + template: '', + // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property + inputs: [], + standalone: true +}) +export class IonSegmentContent { + protected el: HTMLElement; + constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) { + c.detach(); + this.el = r.nativeElement; + } +} + + +export declare interface IonSegmentContent extends Components.IonSegmentContent {} + + +@ProxyCmp({ + defineCustomElementFn: defineIonSegmentView, + inputs: ['disabled'] +}) +@Component({ + selector: 'ion-segment-view', + changeDetection: ChangeDetectionStrategy.OnPush, + template: '', + // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property + inputs: ['disabled'], + standalone: true +}) +export class IonSegmentView { + protected el: HTMLElement; + constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) { + c.detach(); + this.el = r.nativeElement; + proxyOutputs(this, this.el, ['ionSegmentViewScroll']); + } +} + + +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>; +} + + @ProxyCmp({ defineCustomElementFn: defineIonSelectModal, inputs: ['header', 'multiple', 'options'] diff --git a/packages/react/src/components/proxies.ts b/packages/react/src/components/proxies.ts index 14f81d30ebd..a9f1416ee22 100644 --- a/packages/react/src/components/proxies.ts +++ b/packages/react/src/components/proxies.ts @@ -61,6 +61,8 @@ import { defineCustomElement as defineIonRow } from '@ionic/core/components/ion- import { defineCustomElement as defineIonSearchbar } from '@ionic/core/components/ion-searchbar.js'; import { defineCustomElement as defineIonSegment } from '@ionic/core/components/ion-segment.js'; import { defineCustomElement as defineIonSegmentButton } from '@ionic/core/components/ion-segment-button.js'; +import { defineCustomElement as defineIonSegmentContent } from '@ionic/core/components/ion-segment-content.js'; +import { defineCustomElement as defineIonSegmentView } from '@ionic/core/components/ion-segment-view.js'; import { defineCustomElement as defineIonSelect } from '@ionic/core/components/ion-select.js'; import { defineCustomElement as defineIonSelectModal } from '@ionic/core/components/ion-select-modal.js'; import { defineCustomElement as defineIonSelectOption } from '@ionic/core/components/ion-select-option.js'; @@ -131,6 +133,8 @@ export const IonRow = /*@__PURE__*/createReactComponent('ion-searchbar', undefined, undefined, defineIonSearchbar); export const IonSegment = /*@__PURE__*/createReactComponent('ion-segment', undefined, undefined, defineIonSegment); export const IonSegmentButton = /*@__PURE__*/createReactComponent('ion-segment-button', undefined, undefined, defineIonSegmentButton); +export const IonSegmentContent = /*@__PURE__*/createReactComponent('ion-segment-content', undefined, undefined, defineIonSegmentContent); +export const IonSegmentView = /*@__PURE__*/createReactComponent('ion-segment-view', undefined, undefined, defineIonSegmentView); export const IonSelect = /*@__PURE__*/createReactComponent('ion-select', undefined, undefined, defineIonSelect); export const IonSelectModal = /*@__PURE__*/createReactComponent('ion-select-modal', undefined, undefined, defineIonSelectModal); export const IonSelectOption = /*@__PURE__*/createReactComponent('ion-select-option', undefined, undefined, defineIonSelectOption); diff --git a/packages/vue/src/proxies.ts b/packages/vue/src/proxies.ts index 0d93a32c46d..7dd5812ebbc 100644 --- a/packages/vue/src/proxies.ts +++ b/packages/vue/src/proxies.ts @@ -67,6 +67,8 @@ import { defineCustomElement as defineIonRow } from '@ionic/core/components/ion- import { defineCustomElement as defineIonSearchbar } from '@ionic/core/components/ion-searchbar.js'; import { defineCustomElement as defineIonSegment } from '@ionic/core/components/ion-segment.js'; import { defineCustomElement as defineIonSegmentButton } from '@ionic/core/components/ion-segment-button.js'; +import { defineCustomElement as defineIonSegmentContent } from '@ionic/core/components/ion-segment-content.js'; +import { defineCustomElement as defineIonSegmentView } from '@ionic/core/components/ion-segment-view.js'; import { defineCustomElement as defineIonSelect } from '@ionic/core/components/ion-select.js'; import { defineCustomElement as defineIonSelectModal } from '@ionic/core/components/ion-select-modal.js'; import { defineCustomElement as defineIonSelectOption } from '@ionic/core/components/ion-select-option.js'; @@ -745,6 +747,7 @@ export const IonSegment = /*@__PURE__*/ defineContainer('ion-segment-button', defineIonSegmentButton, [ + 'contentId', 'disabled', 'layout', 'type', @@ -753,6 +756,15 @@ export const IonSegmentButton = /*@__PURE__*/ defineContainer('ion-segment-content', defineIonSegmentContent); + + +export const IonSegmentView = /*@__PURE__*/ defineContainer('ion-segment-view', defineIonSegmentView, [ + 'disabled', + 'ionSegmentViewScroll' +]); + + export const IonSelect = /*@__PURE__*/ defineContainer('ion-select', defineIonSelect, [ 'cancelText', 'color',