Compare commits

...

58 Commits

Author SHA1 Message Date
Tanner Reits
c8b5658b8a fix quick scroll with different segment button widths 2024-10-22 16:32:02 -04:00
Tanner Reits
d6f7cc86ab fix: handle button clicking scrolling multiple views 2024-10-18 17:19:27 -04:00
Tanner Reits
88b9af9a44 fix: calculate distance to button using rect & container scroll offset 2024-10-18 16:23:22 -04:00
Tanner Reits
c4c89ae005 only use segmentViewId to query segment-view 2024-10-18 13:34:58 -04:00
Tanner Reits
cf51b7127c add intersection observer for segment/view init 2024-10-18 12:56:48 -04:00
Tanner Reits
8e50616d42 add segmentViewId to link segment w/ segment-view 2024-10-18 12:51:24 -04:00
Tanner Reits
d804924f8f fix: styles 2024-10-15 18:26:56 -04:00
Tanner Reits
95c5d1b86c fix: initial scroll must always be multiple of content offset 2024-10-15 17:35:47 -04:00
Tanner Reits
e26c8ff8ad fix: lol web APIs exist 2024-10-15 16:33:40 -04:00
Tanner Reits
7b67c26ba0 fix: math is hard 2024-10-15 16:26:20 -04:00
Tanner Reits
a75baa1f10 fix: button click scroll 2024-10-15 15:53:31 -04:00
Tanner Reits
7da0beb27f fancy color gradient 2024-10-11 16:44:21 -04:00
Tanner Reits
f171202ac7 fix segment right scroll 2024-10-11 15:51:47 -04:00
Tanner Reits
1bb7fe87f7 scroll end timing 2024-10-11 15:49:15 -04:00
Tanner Reits
9823e0d2a9 scroll segment view w/ indicator 2024-10-11 15:15:14 -04:00
Tanner Reits
26c2697830 disabled buttons 2024-10-11 15:11:26 -04:00
Tanner Reits
ca3e43bc3b transition indicator color 2024-10-11 13:13:34 -04:00
Tanner Reits
b8dd17eae7 support translation & width scaling 2024-10-11 12:47:19 -04:00
Brandy Carney
475de8b6c7 fix(segment-view): continue to search through segment contents for enabled 2024-10-10 12:04:03 -04:00
Brandy Carney
b94bec20de fix(segment): update segment content to disabled when button is 2024-10-09 18:05:17 -04:00
Brandy Carney
279300fd3e fix(segment): update segment view to scroll past disabled content 2024-10-08 17:35:14 -04:00
Brandy Carney
59e306bd6d test(segment-view): remove not disabled styles 2024-10-02 11:41:36 -04:00
Brandy Carney
641dc0b82a refactor(segment-content): use opacity for disabled content 2024-10-01 16:04:29 -04:00
Brandy Carney
e6547432e5 style: naming 2024-09-30 09:57:22 -04:00
Brandy Carney
c6ec156dcb chore: build 2024-09-30 09:51:19 -04:00
Tanner Reits
89d8e90ab7 fix(segment): scroll segment button into view if appropriate 2024-09-28 20:59:22 -04:00
Tanner Reits
ae2704fdc8 fix(segment): only handle events for correct instance 2024-09-28 20:33:48 -04:00
Tanner Reits
16c728b040 fix(segment): don't trigger scroll listener on segment button click 2024-09-28 20:29:03 -04:00
Tanner Reits
0fa5c99d99 fix(segment): handle change of direction scrolling 2024-09-27 18:34:19 -04:00
Tanner Reits
0a13ab449a fix(segment): clear transform styles on scroll end 2024-09-27 18:27:32 -04:00
Brandy Carney
cbee05e488 fix(segment): move indicator as a percentage of the width on scroll 2024-09-25 16:52:37 -04:00
Brandy Carney
bdc6933cf6 fix(segment): properly bound indicator transform for more than 2 contents 2024-09-25 15:50:39 -04:00
Brandy Carney
7fe1c094ec fix(segment-view): always check the scrollLeft against the initial to get scrollDistance 2024-09-25 15:16:54 -04:00
Brandy Carney
c7bae079c2 fix(segment-view): allow moving the indicator left on scroll without touch 2024-09-25 12:00:52 -04:00
Brandy Carney
1d645c9f3f style: lint 2024-09-24 19:55:25 -04:00
Brandy Carney
699ce9779f fix(segment): properly move the indicator when direction starts out on the left 2024-09-24 19:54:44 -04:00
Brandy Carney
d811221750 feat(segment): move indicator with scroll 2024-09-24 19:22:46 -04:00
Brandy Carney
798e725712 test: remove only 2024-09-24 11:38:13 -04:00
Brandy Carney
15b8b8fe68 chore(): add updated snapshots 2024-09-24 11:27:25 -04:00
Brandy Carney
522cbc6180 test(segment-view): add tests for disabled content scrolling 2024-09-24 11:27:13 -04:00
Brandy Carney
094d9a8553 test(segment-view): remove toolbars 2024-09-24 11:26:49 -04:00
Brandy Carney
b75650b2f7 fix(segment-view): don't query for disabled contents 2024-09-23 19:05:00 -04:00
Brandy Carney
f07a5b1039 fix(segment-view): split opacity by mode vars to match segment 2024-09-23 18:50:39 -04:00
Brandy Carney
5401e8dc24 test(segment-view): split out disabled segment view / content test 2024-09-23 18:44:14 -04:00
Brandy Carney
faa7065a70 feat(segment-content): add disabled prop and hide the content 2024-09-23 18:42:46 -04:00
Brandy Carney
d8f27d8f7b test(segment-view): update function for clearing segment value 2024-09-23 18:17:56 -04:00
Brandy Carney
4c0407ed52 test(segment-view): fix test 2024-09-23 16:23:37 -04:00
Brandy Carney
9103c403b2 docs(segment-view): document setContent method and add example 2024-09-23 16:18:37 -04:00
Brandy Carney
e6f76d53a9 style: lint 2024-09-23 15:57:52 -04:00
Brandy Carney
686d943b65 test(segment-view): add a test for the proper content being displayed 2024-09-23 15:56:33 -04:00
Brandy Carney
ba285306d6 fix(segment): set the view to the initial value without scrolling 2024-09-23 15:40:38 -04:00
Brandy Carney
44e8374791 fix(segment): only call updateSegmentView when gesture ends or button is clicked 2024-09-23 14:29:03 -04:00
Brandy Carney
678990d77a feat(segment-view): support disabled 2024-09-19 14:12:34 -04:00
Brandy Carney
44a0855844 refactor(segment): remove uneccessary function 2024-09-18 15:39:19 -04:00
Brandy Carney
00c378f0f5 refactor(segment): link the button and content with content-id and id 2024-09-18 15:32:48 -04:00
Brandy Carney
8af4d74846 style: lint 2024-09-17 14:17:45 -04:00
Brandy Carney
0324a78c2b feat(segment): add segment view and content components 2024-09-17 13:51:51 -04:00
Brandy Carney
fa74077820 fix(segment): animate the highlight with value changes 2024-09-17 13:15:54 -04:00
30 changed files with 1353 additions and 35 deletions

View File

@@ -1541,6 +1541,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
@@ -1606,6 +1607,16 @@ ion-segment-button,part,indicator
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<void>
ion-segment-view,event,ionSegmentViewScroll,{ scrollDirection: string; scrollDistance: number; scrollDistancePercentage: number; },true
ion-segment-view,event,ionSegmentViewScrollEnd,{ activeContentId: string; },true
ion-segment-view,event,ionSegmentViewScrollStart,void,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<never, never> | undefined,undefined,false,true

View File

@@ -2676,6 +2676,10 @@ export namespace Components {
* If `true`, the segment buttons will overflow and the user can swipe to see them. In addition, this will disable the gesture to drag the indicator between the buttons in order to swipe to see hidden buttons.
*/
"scrollable": boolean;
/**
* The `id` of the `segment-view` element to be associated with this segment.
*/
"segmentViewId"?: string;
/**
* If `true`, navigating to an `ion-segment-button` with the keyboard will focus and select the element. If `false`, keyboard navigation will only focus the `ion-segment-button` element.
*/
@@ -2690,10 +2694,15 @@ 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.
*/
"disabled": boolean;
"hasIndicator": boolean;
/**
* Set the layout of the text and icon in the segment.
*/
@@ -2712,6 +2721,24 @@ export namespace Components {
*/
"value": SegmentValue;
}
interface IonSegmentContent {
/**
* If `true`, the segment content will not be displayed.
*/
"disabled": boolean;
}
interface IonSegmentView {
/**
* If `true`, the segment view cannot be interacted with.
*/
"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.
*/
"setContent": (id: string, smoothScroll?: boolean) => Promise<void>;
}
interface IonSelect {
/**
* The text to display on the cancel button.
@@ -3413,6 +3440,10 @@ export interface IonSegmentCustomEvent<T> extends CustomEvent<T> {
detail: T;
target: HTMLIonSegmentElement;
}
export interface IonSegmentViewCustomEvent<T> extends CustomEvent<T> {
detail: T;
target: HTMLIonSegmentViewElement;
}
export interface IonSelectCustomEvent<T> extends CustomEvent<T> {
detail: T;
target: HTMLIonSelectElement;
@@ -4409,6 +4440,35 @@ declare global {
prototype: HTMLIonSegmentButtonElement;
new (): HTMLIonSegmentButtonElement;
};
interface HTMLIonSegmentContentElement extends Components.IonSegmentContent, HTMLStencilElement {
}
var HTMLIonSegmentContentElement: {
prototype: HTMLIonSegmentContentElement;
new (): HTMLIonSegmentContentElement;
};
interface HTMLIonSegmentViewElementEventMap {
"ionSegmentViewScroll": {
scrollDirection: string;
scrollDistance: number;
scrollDistancePercentage: number;
};
"ionSegmentViewScrollEnd": { activeContentId: string };
"ionSegmentViewScrollStart": void;
}
interface HTMLIonSegmentViewElement extends Components.IonSegmentView, HTMLStencilElement {
addEventListener<K extends keyof HTMLIonSegmentViewElementEventMap>(type: K, listener: (this: HTMLIonSegmentViewElement, ev: IonSegmentViewCustomEvent<HTMLIonSegmentViewElementEventMap[K]>) => any, options?: boolean | AddEventListenerOptions): void;
addEventListener<K extends keyof DocumentEventMap>(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
addEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
removeEventListener<K extends keyof HTMLIonSegmentViewElementEventMap>(type: K, listener: (this: HTMLIonSegmentViewElement, ev: IonSegmentViewCustomEvent<HTMLIonSegmentViewElementEventMap[K]>) => any, options?: boolean | EventListenerOptions): void;
removeEventListener<K extends keyof DocumentEventMap>(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
removeEventListener<K extends keyof HTMLElementEventMap>(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;
@@ -4718,6 +4778,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-option": HTMLIonSelectOptionElement;
"ion-select-popover": HTMLIonSelectPopoverElement;
@@ -7433,6 +7495,10 @@ declare namespace LocalJSX {
* If `true`, the segment buttons will overflow and the user can swipe to see them. In addition, this will disable the gesture to drag the indicator between the buttons in order to swipe to see hidden buttons.
*/
"scrollable"?: boolean;
/**
* The `id` of the `segment-view` element to be associated with this segment.
*/
"segmentViewId"?: string;
/**
* If `true`, navigating to an `ion-segment-button` with the keyboard will focus and select the element. If `false`, keyboard navigation will only focus the `ion-segment-button` element.
*/
@@ -7447,10 +7513,15 @@ 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.
*/
"disabled"?: boolean;
"hasIndicator"?: boolean;
/**
* Set the layout of the text and icon in the segment.
*/
@@ -7468,6 +7539,31 @@ declare namespace LocalJSX {
*/
"value"?: SegmentValue;
}
interface IonSegmentContent {
/**
* If `true`, the segment content will not be displayed.
*/
"disabled"?: boolean;
}
interface IonSegmentView {
/**
* If `true`, the segment view cannot be interacted with.
*/
"disabled"?: boolean;
/**
* Emitted when the segment view is scrolled.
*/
"onIonSegmentViewScroll"?: (event: IonSegmentViewCustomEvent<{
scrollDirection: string;
scrollDistance: number;
scrollDistancePercentage: number;
}>) => void;
/**
* Emitted when the segment view scroll has ended.
*/
"onIonSegmentViewScrollEnd"?: (event: IonSegmentViewCustomEvent<{ activeContentId: string }>) => void;
"onIonSegmentViewScrollStart"?: (event: IonSegmentViewCustomEvent<void>) => void;
}
interface IonSelect {
/**
* The text to display on the cancel button.
@@ -8159,6 +8255,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-option": IonSelectOption;
"ion-select-popover": IonSelectPopover;
@@ -8258,6 +8356,8 @@ declare module "@stencil/core" {
"ion-searchbar": LocalJSX.IonSearchbar & JSXBase.HTMLAttributes<HTMLIonSearchbarElement>;
"ion-segment": LocalJSX.IonSegment & JSXBase.HTMLAttributes<HTMLIonSegmentElement>;
"ion-segment-button": LocalJSX.IonSegmentButton & JSXBase.HTMLAttributes<HTMLIonSegmentButtonElement>;
"ion-segment-content": LocalJSX.IonSegmentContent & JSXBase.HTMLAttributes<HTMLIonSegmentContentElement>;
"ion-segment-view": LocalJSX.IonSegmentView & JSXBase.HTMLAttributes<HTMLIonSegmentViewElement>;
"ion-select": LocalJSX.IonSelect & JSXBase.HTMLAttributes<HTMLIonSelectElement>;
"ion-select-option": LocalJSX.IonSelectOption & JSXBase.HTMLAttributes<HTMLIonSelectOptionElement>;
"ion-select-popover": LocalJSX.IonSelectPopover & JSXBase.HTMLAttributes<HTMLIonSelectPopoverElement>;

View File

@@ -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.
*/
@@ -60,6 +65,8 @@ export class SegmentButton implements ComponentInterface, ButtonInterface {
this.updateState();
}
@Prop() hasIndicator = true;
connectedCallback() {
const segmentEl = (this.segmentEl = this.el.closest('ion-segment'));
if (segmentEl) {
@@ -67,6 +74,27 @@ 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 <ion-segment-content> element.`);
return;
}
// Set the disabled state of the Segment Content based on the button's disabled state
segmentContent.disabled = this.disabled;
}
disconnectedCallback() {
@@ -161,15 +189,11 @@ export class SegmentButton implements ComponentInterface, ButtonInterface {
</span>
{mode === 'md' && <ion-ripple-effect></ion-ripple-effect>}
</button>
<div
part="indicator"
class={{
'segment-button-indicator': true,
'segment-button-indicator-animated': true,
}}
>
<div part="indicator-background" class="segment-button-indicator-background"></div>
</div>
{this.hasIndicator && (
<div part="indicator" class="segment-button-indicator segment-button-indicator-animated">
<div part="indicator-background" class="segment-button-indicator-background"></div>
</div>
)}
</Host>
);
}

View File

@@ -0,0 +1,5 @@
@import "./segment-content";
@import "../segment-button/segment-button.ios.vars";
// iOS Segment Content
// --------------------------------------------------

View File

@@ -0,0 +1,5 @@
@import "./segment-content";
@import "../segment-button/segment-button.md.vars";
// Material Design Segment Content
// --------------------------------------------------

View File

@@ -0,0 +1,15 @@
// Segment Content
// --------------------------------------------------
:host {
scroll-snap-align: center;
scroll-snap-stop: always;
flex-shrink: 0;
width: 100%;
}
:host(.segment-content-disabled) {
display: none;
}

View File

@@ -0,0 +1,31 @@
import type { ComponentInterface } from '@stencil/core';
import { Component, Host, Prop, h } from '@stencil/core';
@Component({
tag: 'ion-segment-content',
styleUrls: {
ios: 'segment-content.ios.scss',
md: 'segment-content.md.scss',
},
shadow: true,
})
export class SegmentContent implements ComponentInterface {
/**
* If `true`, the segment content will not be displayed.
*/
@Prop() disabled = false;
render() {
const { disabled } = this;
return (
<Host
class={{
'segment-content-disabled': disabled,
}}
>
<slot></slot>
</Host>
);
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,29 @@
// Segment View
// --------------------------------------------------
:host {
display: flex;
height: 100%;
overflow-x: scroll;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
/* 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;
}

View File

@@ -0,0 +1,153 @@
import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Component, Element, Event, Host, Listen, Method, Prop, h } from '@stencil/core';
@Component({
tag: 'ion-segment-view',
styleUrls: {
ios: 'segment-view.ios.scss',
md: 'segment-view.md.scss',
},
shadow: true,
})
export class SegmentView implements ComponentInterface {
private initialScrollLeft?: number;
private previousScrollLeft = 0;
private scrollEndTimeout: ReturnType<typeof setTimeout> | null = null;
private isTouching = false;
@Element() el!: HTMLElement;
/**
* If `true`, the segment view cannot be interacted with.
*/
@Prop() disabled = false;
/**
* Emitted when the segment view is scrolled.
*/
@Event() ionSegmentViewScroll!: EventEmitter<{
scrollDirection: string;
scrollDistance: number;
scrollDistancePercentage: number;
}>;
/**
* Emitted when the segment view scroll has ended.
*/
@Event() ionSegmentViewScrollEnd!: EventEmitter<{ activeContentId: string }>;
@Event() ionSegmentViewScrollStart!: EventEmitter<void>;
private activeContentId = '';
@Listen('scroll')
handleScroll(ev: Event) {
const { previousScrollLeft } = this;
const { scrollLeft, offsetWidth } = ev.target as HTMLElement;
// Set initial scroll position if it's undefined
// Must be a multiple of the offset width
if (this.initialScrollLeft === undefined) {
this.initialScrollLeft = Math.round(scrollLeft / offsetWidth) * offsetWidth;
}
// Determine the scroll direction based on the previous scroll position
const scrollDirection = scrollLeft > previousScrollLeft ? 'right' : 'left';
this.previousScrollLeft = scrollLeft;
// 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,
});
// 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);
// // Update active content ID and scroll to the segment content
const activeContent = this.getSegmentContents().filter(
(ref) => !ref.classList.contains('segment-content-disabled')
)[currentIndex];
this.activeContentId = activeContent.id;
// 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;
}
}
/**
* Handle touch start event to know when the user is actively dragging the segment view.
*/
@Listen('touchstart')
handleScrollStart() {
this.ionSegmentViewScrollStart.emit();
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;
}
/**
* 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;
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 } = this;
return (
<Host
class={{
'segment-view-disabled': disabled,
}}
>
<slot></slot>
</Host>
);
}
}

View File

@@ -0,0 +1,134 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Segment View - Basic</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.js"></script>
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
<style>
ion-segment-view {
height: 100px;
}
ion-segment-content {
display: flex;
justify-content: center;
align-items: center;
}
ion-segment-content:nth-of-type(1) {
background: lightpink;
}
ion-segment-content:nth-of-type(2) {
background: lightblue;
}
ion-segment-content:nth-of-type(3) {
background: lightgreen;
}
</style>
</head>
<body>
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>Segment View - Basic</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-segment id="noValueSegment">
<ion-segment-button content-id="no" value="no">
<ion-label>No</ion-label>
</ion-segment-button>
<ion-segment-button content-id="value" value="value">
<ion-label>Value</ion-label>
</ion-segment-button>
</ion-segment>
<ion-segment-view id="noValueSegmentView">
<ion-segment-content id="no">No</ion-segment-content>
<ion-segment-content id="value">Value</ion-segment-content>
</ion-segment-view>
<ion-segment value="all">
<ion-segment-button content-id="all" value="all">
<ion-label>All</ion-label>
</ion-segment-button>
<ion-segment-button content-id="favorites" value="favorites">
<ion-label>Favorites</ion-label>
</ion-segment-button>
</ion-segment>
<ion-segment-view>
<ion-segment-content id="all">All</ion-segment-content>
<ion-segment-content id="favorites">Favorites</ion-segment-content>
</ion-segment-view>
<ion-segment value="free">
<ion-segment-button content-id="paid" value="paid">
<ion-label>Paid</ion-label>
</ion-segment-button>
<ion-segment-button content-id="free" value="free">
<ion-label>Free</ion-label>
</ion-segment-button>
<ion-segment-button content-id="top" value="top">
<ion-label>Top</ion-label>
</ion-segment-button>
</ion-segment>
<ion-segment-view>
<ion-segment-content id="paid">Paid</ion-segment-content>
<ion-segment-content id="free">Free</ion-segment-content>
<ion-segment-content id="top">Top</ion-segment-content>
</ion-segment-view>
<button class="expand" onClick="changeSegmentContent()">Change Segment Content</button>
<button class="expand" onClick="clearSegmentValue()">Clear Segment Value</button>
</ion-content>
<ion-footer>
<ion-toolbar>
<ion-title>Footer</ion-title>
</ion-toolbar>
</ion-footer>
<script>
function changeSegmentContent() {
const segment = document.querySelector('#noValueSegment');
const segmentView = document.querySelector('#noValueSegmentView');
let currentValue = segment.value;
if (currentValue === 'value') {
currentValue = 'no';
} else {
currentValue = 'value';
}
segmentView.setContent(currentValue);
}
async function clearSegmentValue() {
const segmentView = document.querySelector('#noValueSegmentView');
segmentView.setContent('no', false);
// Set timeout to ensure the value is cleared after
// the segment content is updated
setTimeout(() => {
const segment = document.querySelector('#noValueSegment');
segment.value = undefined;
});
}
</script>
</ion-app>
</body>
</html>

View File

@@ -0,0 +1,92 @@
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(
`
<ion-segment>
<ion-segment-button content-id="paid" value="paid">
<ion-label>Paid</ion-label>
</ion-segment-button>
<ion-segment-button content-id="free" value="free">
<ion-label>Free</ion-label>
</ion-segment-button>
<ion-segment-button content-id="top" value="top">
<ion-label>Top</ion-label>
</ion-segment-button>
</ion-segment>
<ion-segment-view>
<ion-segment-content id="paid">Paid</ion-segment-content>
<ion-segment-content id="free">Free</ion-segment-content>
<ion-segment-content id="top">Top</ion-segment-content>
</ion-segment-view>
`,
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(
`
<ion-segment value="free">
<ion-segment-button content-id="paid" value="paid">
<ion-label>Paid</ion-label>
</ion-segment-button>
<ion-segment-button content-id="free" value="free">
<ion-label>Free</ion-label>
</ion-segment-button>
<ion-segment-button content-id="top" value="top">
<ion-label>Top</ion-label>
</ion-segment-button>
</ion-segment>
<ion-segment-view>
<ion-segment-content id="paid">Paid</ion-segment-content>
<ion-segment-content id="free">Free</ion-segment-content>
<ion-segment-content id="top">Top</ion-segment-content>
</ion-segment-view>
`,
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(
`
<ion-segment value="free">
<ion-segment-button content-id="paid" value="paid">
<ion-label>Paid</ion-label>
</ion-segment-button>
<ion-segment-button content-id="free" value="free">
<ion-label>Free</ion-label>
</ion-segment-button>
<ion-segment-button content-id="top" value="top">
<ion-label>Top</ion-label>
</ion-segment-button>
</ion-segment>
<ion-segment-view>
<ion-segment-content id="paid">Paid</ion-segment-content>
<ion-segment-content id="free">Free</ion-segment-content>
<ion-segment-content id="top">Top</ion-segment-content>
</ion-segment-view>
`,
config
);
await page.click('ion-segment-button[value="top"]');
const segmentContent = page.locator('ion-segment-content[id="top"]');
await expect(segmentContent).toBeInViewport();
});
});
});

View File

@@ -0,0 +1,124 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Segment View - Disabled</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.js"></script>
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
<style>
ion-segment-view {
height: 100px;
}
ion-segment-content {
display: flex;
justify-content: center;
align-items: center;
}
ion-segment-content:nth-of-type(1) {
background: lightpink;
}
ion-segment-content:nth-of-type(2) {
background: lightblue;
}
ion-segment-content:nth-of-type(3) {
background: lightgreen;
}
ion-segment-content:nth-of-type(4) {
background: lightgoldenrodyellow;
}
</style>
</head>
<body>
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>Segment View - Disabled</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-segment>
<ion-segment-button disabled content-id="all" value="all">
<ion-label>All</ion-label>
</ion-segment-button>
<ion-segment-button content-id="favorites" value="favorites">
<ion-label>Favorites</ion-label>
</ion-segment-button>
</ion-segment>
<ion-segment-view>
<ion-segment-content id="all">All</ion-segment-content>
<ion-segment-content id="favorites">Favorites</ion-segment-content>
</ion-segment-view>
<ion-segment value="paid">
<ion-segment-button content-id="paid" value="paid">
<ion-label>Paid</ion-label>
</ion-segment-button>
<ion-segment-button disabled content-id="free" value="free">
<ion-label>Free</ion-label>
</ion-segment-button>
<ion-segment-button content-id="top" value="top">
<ion-label>Top</ion-label>
</ion-segment-button>
</ion-segment>
<ion-segment-view>
<ion-segment-content id="paid">Paid</ion-segment-content>
<ion-segment-content id="free">Free</ion-segment-content>
<ion-segment-content id="top">Top</ion-segment-content>
</ion-segment-view>
<ion-segment value="a">
<ion-segment-button content-id="a" value="a">
<ion-label>a</ion-label>
</ion-segment-button>
<ion-segment-button disabled content-id="b" value="b">
<ion-label>b</ion-label>
</ion-segment-button>
<ion-segment-button disabled content-id="c" value="c">
<ion-label>c</ion-label>
</ion-segment-button>
<ion-segment-button content-id="d" value="d">
<ion-label>d</ion-label>
</ion-segment-button>
</ion-segment>
<ion-segment-view>
<ion-segment-content id="a">a</ion-segment-content>
<ion-segment-content id="b">b</ion-segment-content>
<ion-segment-content id="c">c</ion-segment-content>
<ion-segment-content id="d">d</ion-segment-content>
</ion-segment-view>
<ion-segment disabled value="reading-list">
<ion-segment-button content-id="bookmarks" value="bookmarks">
<ion-label>Bookmarks</ion-label>
</ion-segment-button>
<ion-segment-button content-id="reading-list" value="reading-list">
<ion-label>Reading List</ion-label>
</ion-segment-button>
<ion-segment-button content-id="shared-links" value="shared-links">
<ion-label>Shared Links</ion-label>
</ion-segment-button>
</ion-segment>
<ion-segment-view disabled>
<ion-segment-content id="bookmarks">Bookmarks</ion-segment-content>
<ion-segment-content id="reading-list">Reading List</ion-segment-content>
<ion-segment-content id="shared-links">Shared Links</ion-segment-content>
</ion-segment-view>
</ion-content>
</ion-app>
</body>
</html>

View File

@@ -0,0 +1,101 @@
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.setContent(
`
<style>
/* Styles are here to show the disabled state */
ion-segment-view {
height: 100px;
background: #3880ff;
}
</style>
<ion-segment-view disabled>
<ion-segment-content id="paid">Paid</ion-segment-content>
<ion-segment-content id="free">Free</ion-segment-content>
<ion-segment-content id="top">Top</ion-segment-content>
</ion-segment-view>
`,
config
);
const segment = page.locator('ion-segment-view');
await expect(segment).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 show the second content when first segment content is disabled', async ({ page }) => {
await page.setContent(
`
<ion-segment disabled>
<ion-segment-button content-id="paid" value="paid">
<ion-label>Paid</ion-label>
</ion-segment-button>
<ion-segment-button content-id="free" value="free">
<ion-label>Free</ion-label>
</ion-segment-button>
<ion-segment-button content-id="top" value="top">
<ion-label>Top</ion-label>
</ion-segment-button>
</ion-segment>
<ion-segment-view>
<ion-segment-content disabled id="paid">Paid</ion-segment-content>
<ion-segment-content id="free">Free</ion-segment-content>
<ion-segment-content id="top">Top</ion-segment-content>
</ion-segment-view>
`,
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,
}) => {
await page.setContent(
`
<ion-segment>
<ion-segment-button content-id="paid" value="paid">
<ion-label>Paid</ion-label>
</ion-segment-button>
<ion-segment-button disabled content-id="free" value="free">
<ion-label>Free</ion-label>
</ion-segment-button>
<ion-segment-button content-id="top" value="top">
<ion-label>Top</ion-label>
</ion-segment-button>
</ion-segment>
<ion-segment-view>
<ion-segment-content id="paid">Paid</ion-segment-content>
<ion-segment-content disabled id="free">Free</ion-segment-content>
<ion-segment-content id="top">Top</ion-segment-content>
</ion-segment-view>
`,
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');
});
});
});

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -6,15 +6,14 @@
:host {
--background: #{$segment-ios-background-color};
@include border-radius($segment-ios-border-radius);
--indicator-height: 100%;
--indicator-box-shadow: 0 0 5px rgba(0, 0, 0, 0.16) @include border-radius($segment-ios-border-radius);
overflow: hidden;
z-index: 0;
}
// Segment: Color
// --------------------------------------------------
@@ -22,7 +21,6 @@
background: #{current-color(base, 0.065)};
}
// Segment: Default Toolbar
// --------------------------------------------------
@@ -37,7 +35,6 @@
background: var(--ion-toolbar-segment-background, var(--background));
}
// Segment: Color Toolbar
// --------------------------------------------------
@@ -45,3 +42,14 @@
:host(.in-toolbar-color:not(.ion-color)) {
background: #{current-color(contrast, 0.11)};
}
.segment-indicator {
@include position(0, 0, 0, 0);
padding: 0 2px;
.segment-indicator-background {
transform: scale(0.95);
border-radius: 7px;
}
}

View File

@@ -6,6 +6,7 @@
:host {
--background: transparent;
--indicator-height: 2px;
grid-auto-columns: minmax(auto, $segment-button-md-max-width);
}
@@ -23,10 +24,13 @@
min-height: var(--min-height);
}
// Segment: Scrollable
// --------------------------------------------------
:host(.segment-scrollable) ::slotted(ion-segment-button) {
min-width: auto;
}
.segment-indicator {
@include position(null, 0, 0, 0);
}

View File

@@ -31,8 +31,23 @@
contain: paint;
user-select: none;
}
.segment-indicator {
@include transform-origin(left);
z-index: -1;
position: absolute;
.segment-indicator-background {
height: var(--indicator-height);
box-shadow: var(--indicator-box-shadow);
background-color: var(--indicator-color);
transition: background-color 0.3s linear;
}
}
}
// Segment: Scrollable
// --------------------------------------------------

View File

@@ -27,10 +27,21 @@ export class Segment implements ComponentInterface {
// Value before the segment is dragged
private valueBeforeGesture?: SegmentValue;
private segmentViewEl?: HTMLIonSegmentViewElement | null = null;
private nextButtonIndex?: number;
private io?: IntersectionObserver;
@Element() el!: HTMLIonSegmentElement;
@State() activated = false;
/**
* The `id` of the `segment-view` element to be associated with this segment.
*/
@Prop() segmentViewId?: string;
/**
* The color to use from your application's color palette.
* Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`.
@@ -78,13 +89,31 @@ 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) {
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 {
this.setCheckedClasses();
}
}
}
/**
* `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 when there is a segment view
if (!this.segmentViewEl) {
this.scrollActiveButtonIntoView();
}
}
/**
@@ -132,6 +161,24 @@ export class Segment implements ComponentInterface {
connectedCallback() {
this.emitStyle();
this.segmentViewEl = this.getSegmentView();
if (this.segmentViewEl) {
// Disable each button indicator when using a segment view
// Instead, a single indicator instance will be used
this.getButtons().forEach((ref) => (ref.hasIndicator = false));
this.addIntersectionObserver();
}
}
disconnectedCallback() {
this.segmentViewEl = null;
if (this.io) {
this.io.disconnect();
this.io = undefined;
}
}
componentWillLoad() {
@@ -191,12 +238,69 @@ export class Segment implements ComponentInterface {
const value = this.value;
if (value !== undefined) {
if (this.valueBeforeGesture !== value) {
this.emitValueChange();
if (this.segmentViewEl) {
this.updateSegmentView(value);
} else {
this.emitValueChange();
}
}
}
this.valueBeforeGesture = undefined;
}
private distanceToButton(index: number) {
const buttons = this.getButtons();
const button = buttons[index];
return button.offsetLeft;
}
private addIntersectionObserver() {
if (
typeof (window as any) !== 'undefined' &&
'IntersectionObserver' in window &&
'IntersectionObserverEntry' in window &&
'isIntersecting' in window.IntersectionObserverEntry.prototype
) {
this.io = new IntersectionObserver((data) => {
/**
* On slower devices, it is possible for an intersection observer entry to contain multiple
* objects in the array. This happens when quickly scrolling an image into view and then out of
* view. In this case, the last object represents the current state of the component.
*/
if (data[data.length - 1].isIntersecting) {
this.updateSegmentView(this.value!, false);
this.initIndicator();
}
});
this.io.observe(this.el);
}
}
private initIndicator() {
writeTask(() => {
if (this.segmentViewEl) {
const buttons = this.getButtons();
const activeButtonIndex = buttons.findIndex((ref) => ref.value === this.value);
if (activeButtonIndex >= 0) {
const activeButtonPosition = buttons[activeButtonIndex].getBoundingClientRect();
const activeButtonStyles = getComputedStyle(buttons[activeButtonIndex]);
const indicator = this.el.shadowRoot!.querySelector('.segment-indicator') as HTMLDivElement | null;
if (indicator) {
const startingX = this.distanceToButton(activeButtonIndex);
indicator.style.width = `${activeButtonPosition.width}px`;
indicator.style.left = `${startingX}px`;
// Setting a CSS variable works around issue where background element might not be rendered yet
this.el.style.setProperty('--indicator-color', activeButtonStyles.getPropertyValue('--indicator-color'));
}
}
}
});
}
/**
* Emits an `ionChange` event.
*
@@ -208,7 +312,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 +328,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;
}
@@ -312,6 +412,202 @@ export class Segment implements ComponentInterface {
}
}
private getSegmentView() {
if (!this.segmentViewId) {
return null;
}
const segmentViewEl = document.getElementById(this.segmentViewId);
if (!segmentViewEl) {
console.warn(`Segment: Unable to find 'ion-segment-view' with id="${this.segmentViewId}"`);
return null;
}
if (segmentViewEl.tagName !== 'ION-SEGMENT-VIEW') {
console.warn(`Segment: Element with id="${this.segmentViewId}" is not an <ion-segment-view> element.`);
return null;
}
return segmentViewEl as HTMLIonSegmentViewElement;
}
@Listen('ionSegmentViewScroll', { target: 'body' })
handleSegmentViewScroll(ev: CustomEvent) {
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();
const currentIndex = buttons.findIndex((button) => button.value === this.value);
const currentButton = buttons[currentIndex];
const indicator = this.el.shadowRoot!.querySelector('.segment-indicator') as HTMLDivElement | null;
// If no buttons are found or there is no value set then do nothing
if (!buttons.length || this.value === undefined || !indicator) return;
const { scrollDistancePercentage, scrollDistance } = ev.detail;
const findIndexFrom = (
array: HTMLIonSegmentButtonElement[],
predicate: (button: HTMLIonSegmentButtonElement) => boolean,
startIndex: number
) => {
for (let i = startIndex; i < array.length; i++) {
if (predicate(array[i])) {
return i;
}
}
return -1;
};
const findIndexFromReverse = (
array: HTMLIonSegmentButtonElement[],
predicate: (button: HTMLIonSegmentButtonElement) => boolean,
startIndex: number
) => {
for (let i = startIndex; i >= 0; i--) {
if (predicate(array[i])) {
return i;
}
}
return -1;
};
// Find the next valid button (i.e. we need to ignore any disabled buttons)
const indexOffset = Math.ceil(scrollDistancePercentage);
const nextIndex =
this.nextButtonIndex ??
(scrollDistance > 0
? findIndexFrom(buttons, (ref) => !ref.disabled, currentIndex + indexOffset)
: findIndexFromReverse(buttons, (ref) => !ref.disabled, currentIndex - indexOffset));
if (nextIndex >= 0 && nextIndex < buttons.length) {
// Figure out the number of disabled buttons between the current and next button
const disabledButtons = (
nextIndex > currentIndex ? buttons.slice(currentIndex, nextIndex) : buttons.slice(nextIndex, currentIndex)
).filter((button) => button.disabled).length;
// Adjust the scroll distance percentage based on the number of "views" scrolled
// We need to do this because all subsequent calculations are based on the assumption that
// only one view can be scrolled at a time, but this is not the case when clicking a segment button
const adjustedScrollDistancePercentage =
scrollDistancePercentage / (Math.abs(nextIndex - currentIndex) - disabledButtons);
const nextButton = buttons[nextIndex];
const nextButtonWidth = nextButton.getBoundingClientRect().width;
const currentButtonWidth = currentButton.getBoundingClientRect().width;
// Scale the width based on the width of the next button
const diff = nextButtonWidth - currentButtonWidth;
const width = currentButtonWidth + diff * adjustedScrollDistancePercentage;
const indicatorStyles = getComputedStyle(indicator);
const indicatorPadding =
parseFloat(indicatorStyles.paddingLeft.replace('px', '')) +
parseFloat(indicatorStyles.paddingRight.replace('px', ''));
indicator.style.width = `${width - indicatorPadding}px`;
// Translate the indicator based on the scroll distance
const distanceToNextButton = this.distanceToButton(nextIndex);
const distanceToCurrentButton = this.distanceToButton(currentIndex);
indicator.style.left =
scrollDistance > 0
? `${
distanceToCurrentButton +
(distanceToNextButton - distanceToCurrentButton) * adjustedScrollDistancePercentage
}px`
: `${
distanceToCurrentButton -
(distanceToCurrentButton - distanceToNextButton) * adjustedScrollDistancePercentage
}px`;
const standardize_color = (str: string) => {
const ctx = document.createElement('canvas').getContext('2d');
ctx!.fillStyle = str;
return ctx!.fillStyle;
};
// Helper function to convert hex to RGB
const hexToRgb = (hex: string) => {
const bigint = parseInt(hex.slice(1), 16);
const r = (bigint >> 16) & 255;
const g = (bigint >> 8) & 255;
const b = bigint & 255;
return { r, g, b };
};
// Helper function to convert RGB to hex
const rgbToHex = (r: number, g: number, b: number) => {
const componentToHex = (c: number) => {
const hex = c.toString(16);
return hex.length === 1 ? '0' + hex : hex;
};
return `#${componentToHex(r)}${componentToHex(g)}${componentToHex(b)}`;
};
// Function to calculate the color in between based on percentage
const interpolateColor = (percentage = adjustedScrollDistancePercentage) => {
const currentColor = standardize_color(getComputedStyle(currentButton).getPropertyValue('--indicator-color'));
const nextColor = standardize_color(getComputedStyle(nextButton).getPropertyValue('--indicator-color'));
const rgb1 = hexToRgb(currentColor);
const rgb2 = hexToRgb(nextColor);
const r = Math.round(rgb1.r + (rgb2.r - rgb1.r) * percentage);
const g = Math.round(rgb1.g + (rgb2.g - rgb1.g) * percentage);
const b = Math.round(rgb1.b + (rgb2.b - rgb1.b) * percentage);
return rgbToHex(r, g, b);
};
indicator.querySelector('div')!.style.backgroundColor = interpolateColor();
if (this.scrollable) {
// Scroll the segment container so the indicator is always in view
indicator.scrollIntoView({
behavior: 'instant',
});
}
}
}
}
@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 (ev.composedPath().includes(segmentViewEl) || dispatchedFrom?.contains(segmentEl)) {
this.value = ev.detail.activeContentId;
this.nextButtonIndex = undefined;
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(value: SegmentValue, smoothScroll = true) {
const buttons = this.getButtons();
const button = buttons.find((btn) => btn.value === value);
// If the button does not have a contentId then there is
// no associated segment view to update
if (!button?.contentId) {
return;
}
if (this.segmentViewEl) {
this.segmentViewEl.setContent(button.contentId, smoothScroll);
}
}
private scrollActiveButtonIntoView(smoothScroll = true) {
const { scrollable, value, el } = this;
@@ -472,10 +768,14 @@ export class Segment implements ComponentInterface {
return;
}
this.value = current.value;
if (current !== previous) {
this.emitValueChange();
if (this.segmentViewEl) {
this.nextButtonIndex = this.getButtons().findIndex((button) => button.value === current.value);
this.updateSegmentView(current.value);
} else {
this.value = current.value;
this.emitValueChange();
}
}
if (this.scrollable || !this.swipeGesture) {
@@ -574,6 +874,11 @@ export class Segment implements ComponentInterface {
'segment-scrollable': this.scrollable,
})}
>
{this.segmentViewEl && (
<div part="indicator" class="segment-indicator">
<div part="indicator-background" class="segment-indicator-background"></div>
</div>
)}
<slot onSlotchange={this.onSlottedItemsChange}></slot>
</Host>
);

View File

@@ -69,6 +69,8 @@ export const DIRECTIVES = [
d.IonSearchbar,
d.IonSegment,
d.IonSegmentButton,
d.IonSegmentContent,
d.IonSegmentView,
d.IonSelect,
d.IonSelectOption,
d.IonSkeletonText,

View File

@@ -1952,14 +1952,14 @@ This event will not emit when programmatically setting the `value` property.
@ProxyCmp({
inputs: ['color', 'disabled', 'mode', 'scrollable', 'selectOnFocus', 'swipeGesture', 'value']
inputs: ['color', 'disabled', 'mode', 'scrollable', 'segmentViewId', 'selectOnFocus', 'swipeGesture', 'value']
})
@Component({
selector: 'ion-segment',
changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>',
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
inputs: ['color', 'disabled', 'mode', 'scrollable', 'selectOnFocus', 'swipeGesture', 'value'],
inputs: ['color', 'disabled', 'mode', 'scrollable', 'segmentViewId', 'selectOnFocus', 'swipeGesture', 'value'],
})
export class IonSegment {
protected el: HTMLElement;
@@ -1984,14 +1984,14 @@ This event will not emit when programmatically setting the `value` property.
@ProxyCmp({
inputs: ['disabled', 'layout', 'mode', 'type', 'value']
inputs: ['contentId', 'disabled', 'hasIndicator', 'layout', 'mode', 'type', 'value']
})
@Component({
selector: 'ion-segment-button',
changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>',
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
inputs: ['disabled', 'layout', 'mode', 'type', 'value'],
inputs: ['contentId', 'disabled', 'hasIndicator', 'layout', 'mode', 'type', 'value'],
})
export class IonSegmentButton {
protected el: HTMLElement;
@@ -2005,6 +2005,63 @@ export class IonSegmentButton {
export declare interface IonSegmentButton extends Components.IonSegmentButton {}
@ProxyCmp({
inputs: ['disabled']
})
@Component({
selector: 'ion-segment-content',
changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>',
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
inputs: ['disabled'],
})
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'],
methods: ['setContent']
})
@Component({
selector: 'ion-segment-view',
changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>',
// 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', 'ionSegmentViewScrollEnd', 'ionSegmentViewScrollStart']);
}
}
export declare interface IonSegmentView extends Components.IonSegmentView {
/**
* Emitted when the segment view is scrolled.
*/
ionSegmentViewScroll: EventEmitter<CustomEvent<{ scrollDirection: string; scrollDistance: number; scrollDistancePercentage: number; }>>;
/**
* Emitted when the segment view scroll has ended.
*/
ionSegmentViewScrollEnd: EventEmitter<CustomEvent<{ activeContentId: string }>>;
ionSegmentViewScrollStart: EventEmitter<CustomEvent<void>>;
}
@ProxyCmp({
inputs: ['cancelText', 'color', 'compareWith', 'disabled', 'expandedIcon', 'fill', 'interface', 'interfaceOptions', 'justify', 'label', 'labelPlacement', 'mode', 'multiple', 'name', 'okText', 'placeholder', 'selectedText', 'shape', 'toggleIcon', 'value'],
methods: ['open']

View File

@@ -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 defineIonSelectOption } from '@ionic/core/components/ion-select-option.js';
import { defineCustomElement as defineIonSkeletonText } from '@ionic/core/components/ion-skeleton-text.js';
import { defineCustomElement as defineIonSpinner } from '@ionic/core/components/ion-spinner.js';
@@ -1816,14 +1818,14 @@ export declare interface IonRow extends Components.IonRow {}
@ProxyCmp({
defineCustomElementFn: defineIonSegmentButton,
inputs: ['disabled', 'layout', 'mode', 'type', 'value']
inputs: ['contentId', 'disabled', 'hasIndicator', 'layout', 'mode', 'type', 'value']
})
@Component({
selector: 'ion-segment-button',
changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>',
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
inputs: ['disabled', 'layout', 'mode', 'type', 'value'],
inputs: ['contentId', 'disabled', 'hasIndicator', 'layout', 'mode', 'type', 'value'],
standalone: true
})
export class IonSegmentButton {
@@ -1838,6 +1840,67 @@ export class IonSegmentButton {
export declare interface IonSegmentButton extends Components.IonSegmentButton {}
@ProxyCmp({
defineCustomElementFn: defineIonSegmentContent,
inputs: ['disabled']
})
@Component({
selector: 'ion-segment-content',
changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>',
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
inputs: ['disabled'],
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'],
methods: ['setContent']
})
@Component({
selector: 'ion-segment-view',
changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>',
// 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', 'ionSegmentViewScrollEnd', 'ionSegmentViewScrollStart']);
}
}
export declare interface IonSegmentView extends Components.IonSegmentView {
/**
* Emitted when the segment view is scrolled.
*/
ionSegmentViewScroll: EventEmitter<CustomEvent<{ scrollDirection: string; scrollDistance: number; scrollDistancePercentage: number; }>>;
/**
* Emitted when the segment view scroll has ended.
*/
ionSegmentViewScrollEnd: EventEmitter<CustomEvent<{ activeContentId: string }>>;
ionSegmentViewScrollStart: EventEmitter<CustomEvent<void>>;
}
@ProxyCmp({
defineCustomElementFn: defineIonSelectOption,
inputs: ['disabled', 'value']

View File

@@ -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 defineIonSelectOption } from '@ionic/core/components/ion-select-option.js';
import { defineCustomElement as defineIonSkeletonText } from '@ionic/core/components/ion-skeleton-text.js';
@@ -130,6 +132,8 @@ export const IonRow = /*@__PURE__*/createReactComponent<JSX.IonRow, HTMLIonRowEl
export const IonSearchbar = /*@__PURE__*/createReactComponent<JSX.IonSearchbar, HTMLIonSearchbarElement>('ion-searchbar', undefined, undefined, defineIonSearchbar);
export const IonSegment = /*@__PURE__*/createReactComponent<JSX.IonSegment, HTMLIonSegmentElement>('ion-segment', undefined, undefined, defineIonSegment);
export const IonSegmentButton = /*@__PURE__*/createReactComponent<JSX.IonSegmentButton, HTMLIonSegmentButtonElement>('ion-segment-button', undefined, undefined, defineIonSegmentButton);
export const IonSegmentContent = /*@__PURE__*/createReactComponent<JSX.IonSegmentContent, HTMLIonSegmentContentElement>('ion-segment-content', undefined, undefined, defineIonSegmentContent);
export const IonSegmentView = /*@__PURE__*/createReactComponent<JSX.IonSegmentView, HTMLIonSegmentViewElement>('ion-segment-view', undefined, undefined, defineIonSegmentView);
export const IonSelect = /*@__PURE__*/createReactComponent<JSX.IonSelect, HTMLIonSelectElement>('ion-select', undefined, undefined, defineIonSelect);
export const IonSelectOption = /*@__PURE__*/createReactComponent<JSX.IonSelectOption, HTMLIonSelectOptionElement>('ion-select-option', undefined, undefined, defineIonSelectOption);
export const IonSkeletonText = /*@__PURE__*/createReactComponent<JSX.IonSkeletonText, HTMLIonSkeletonTextElement>('ion-skeleton-text', undefined, undefined, defineIonSkeletonText);

View File

@@ -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 defineIonSelectOption } from '@ionic/core/components/ion-select-option.js';
import { defineCustomElement as defineIonSkeletonText } from '@ionic/core/components/ion-skeleton-text.js';
@@ -730,6 +732,7 @@ export const IonSearchbar = /*@__PURE__*/ defineContainer<JSX.IonSearchbar, JSX.
export const IonSegment = /*@__PURE__*/ defineContainer<JSX.IonSegment, JSX.IonSegment["value"]>('ion-segment', defineIonSegment, [
'segmentViewId',
'color',
'disabled',
'scrollable',
@@ -744,14 +747,29 @@ export const IonSegment = /*@__PURE__*/ defineContainer<JSX.IonSegment, JSX.IonS
export const IonSegmentButton = /*@__PURE__*/ defineContainer<JSX.IonSegmentButton, JSX.IonSegmentButton["value"]>('ion-segment-button', defineIonSegmentButton, [
'contentId',
'disabled',
'layout',
'type',
'value'
'value',
'hasIndicator'
],
'value', 'ion-change');
export const IonSegmentContent = /*@__PURE__*/ defineContainer<JSX.IonSegmentContent>('ion-segment-content', defineIonSegmentContent, [
'disabled'
]);
export const IonSegmentView = /*@__PURE__*/ defineContainer<JSX.IonSegmentView>('ion-segment-view', defineIonSegmentView, [
'disabled',
'ionSegmentViewScroll',
'ionSegmentViewScrollEnd',
'ionSegmentViewScrollStart'
]);
export const IonSelect = /*@__PURE__*/ defineContainer<JSX.IonSelect, JSX.IonSelect["value"]>('ion-select', defineIonSelect, [
'cancelText',
'color',