Skip to content

Commit d811221

Browse files
committed
feat(segment): move indicator with scroll
1 parent 798e725 commit d811221

File tree

7 files changed

+112
-12
lines changed

7 files changed

+112
-12
lines changed

core/src/components.d.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3435,6 +3435,10 @@ export interface IonSegmentCustomEvent<T> extends CustomEvent<T> {
34353435
detail: T;
34363436
target: HTMLIonSegmentElement;
34373437
}
3438+
export interface IonSegmentViewCustomEvent<T> extends CustomEvent<T> {
3439+
detail: T;
3440+
target: HTMLIonSegmentViewElement;
3441+
}
34383442
export interface IonSelectCustomEvent<T> extends CustomEvent<T> {
34393443
detail: T;
34403444
target: HTMLIonSelectElement;
@@ -4437,7 +4441,18 @@ declare global {
44374441
prototype: HTMLIonSegmentContentElement;
44384442
new (): HTMLIonSegmentContentElement;
44394443
};
4444+
interface HTMLIonSegmentViewElementEventMap {
4445+
"ionSegmentViewScroll": { scrollDirection: string; scrollDistance: number };
4446+
}
44404447
interface HTMLIonSegmentViewElement extends Components.IonSegmentView, HTMLStencilElement {
4448+
addEventListener<K extends keyof HTMLIonSegmentViewElementEventMap>(type: K, listener: (this: HTMLIonSegmentViewElement, ev: IonSegmentViewCustomEvent<HTMLIonSegmentViewElementEventMap[K]>) => any, options?: boolean | AddEventListenerOptions): void;
4449+
addEventListener<K extends keyof DocumentEventMap>(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
4450+
addEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
4451+
addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
4452+
removeEventListener<K extends keyof HTMLIonSegmentViewElementEventMap>(type: K, listener: (this: HTMLIonSegmentViewElement, ev: IonSegmentViewCustomEvent<HTMLIonSegmentViewElementEventMap[K]>) => any, options?: boolean | EventListenerOptions): void;
4453+
removeEventListener<K extends keyof DocumentEventMap>(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
4454+
removeEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
4455+
removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void;
44414456
}
44424457
var HTMLIonSegmentViewElement: {
44434458
prototype: HTMLIonSegmentViewElement;
@@ -7519,6 +7534,7 @@ declare namespace LocalJSX {
75197534
* If `true`, the segment view cannot be interacted with.
75207535
*/
75217536
"disabled"?: boolean;
7537+
"onIonSegmentViewScroll"?: (event: IonSegmentViewCustomEvent<{ scrollDirection: string; scrollDistance: number }>) => void;
75227538
}
75237539
interface IonSelect {
75247540
/**

core/src/components/segment-button/segment-button.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -168,10 +168,7 @@ export class SegmentButton implements ComponentInterface, ButtonInterface {
168168
</button>
169169
<div
170170
part="indicator"
171-
class={{
172-
'segment-button-indicator': true,
173-
'segment-button-indicator-animated': true,
174-
}}
171+
class="segment-button-indicator segment-button-indicator-animated"
175172
>
176173
<div part="indicator-background" class="segment-button-indicator-background"></div>
177174
</div>

core/src/components/segment-view/segment-view.tsx

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import type { ComponentInterface } from '@stencil/core';
2-
import { Component, Element, Host, Listen, Method, Prop, h } from '@stencil/core';
1+
import type { ComponentInterface, EventEmitter } from '@stencil/core';
2+
import { Component, Element, Event, Host, Listen, Method, Prop, h } from '@stencil/core';
33

44
@Component({
55
tag: 'ion-segment-view',
@@ -10,16 +10,32 @@ import { Component, Element, Host, Listen, Method, Prop, h } from '@stencil/core
1010
shadow: true,
1111
})
1212
export class SegmentView implements ComponentInterface {
13+
private previousScrollLeft = 0;
14+
1315
@Element() el!: HTMLElement;
1416

1517
/**
1618
* If `true`, the segment view cannot be interacted with.
1719
*/
1820
@Prop() disabled = false;
1921

22+
@Event() ionSegmentViewScroll!: EventEmitter<{ scrollDirection: string; scrollDistance: number }>;
23+
2024
@Listen('scroll')
21-
handleScroll(ev: any) {
22-
const { scrollLeft, offsetWidth } = ev.target;
25+
handleScroll(ev: Event) {
26+
const { scrollLeft, offsetWidth } = ev.target as HTMLElement;
27+
28+
const scrollDirection = scrollLeft > this.previousScrollLeft ? 'right' : 'left';
29+
this.previousScrollLeft = scrollLeft;
30+
31+
const scrollDistance = scrollLeft;
32+
33+
// Emit the scroll direction and distance
34+
this.ionSegmentViewScroll.emit({
35+
scrollDirection,
36+
scrollDistance
37+
});
38+
2339
const atSnappingPoint = scrollLeft % offsetWidth === 0;
2440

2541
if (!atSnappingPoint) return;
@@ -57,7 +73,7 @@ export class SegmentView implements ComponentInterface {
5773
this.el.scrollTo({
5874
top: 0,
5975
left: index * contentWidth,
60-
behavior: smoothScroll ? 'smooth' : 'auto',
76+
behavior: smoothScroll ? 'smooth' : 'instant',
6177
});
6278
}
6379

core/src/components/segment/segment.tsx

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ export class Segment implements ComponentInterface {
2727
// Value before the segment is dragged
2828
private valueBeforeGesture?: SegmentValue;
2929

30+
private segmentViewEl?: HTMLIonSegmentViewElement | null = null;
31+
3032
@Element() el!: HTMLIonSegmentElement;
3133

3234
@State() activated = false;
@@ -142,6 +144,12 @@ export class Segment implements ComponentInterface {
142144

143145
connectedCallback() {
144146
this.emitStyle();
147+
148+
this.segmentViewEl = this.getSegmentView();
149+
}
150+
151+
disconnectedCallback() {
152+
this.segmentViewEl = null;
145153
}
146154

147155
componentWillLoad() {
@@ -323,6 +331,60 @@ export class Segment implements ComponentInterface {
323331
}
324332
}
325333

334+
private getSegmentView() {
335+
const buttons = this.getButtons();
336+
// Get the first button with a contentId
337+
const firstContentId = buttons.find((button: HTMLIonSegmentButtonElement) => button.contentId);
338+
// Get the segment content with an id matching the button's contentId
339+
const segmentContent = document.querySelector(`ion-segment-content[id="${firstContentId?.contentId}"]`);
340+
// Return the segment view for that matching segment content
341+
return segmentContent?.closest('ion-segment-view');
342+
}
343+
344+
@Listen('ionSegmentViewScroll', { target: 'body' })
345+
handleSegmentViewScroll(ev: CustomEvent) {
346+
const dispatchedFrom = ev.target as HTMLElement;
347+
const segmentViewEl = this.segmentViewEl as EventTarget;
348+
const segmentEl = this.el;
349+
350+
// Only update the indicator if the event was dispatched from the segment view
351+
// containing the segment contents that matches this segment's buttons
352+
if (ev.composedPath().includes(segmentViewEl) || dispatchedFrom?.contains(segmentEl)) {
353+
const buttons = this.getButtons();
354+
355+
// If no buttons are found or there is no value set then do nothing
356+
if (!buttons.length || this.value === undefined) return;
357+
358+
const index = buttons.findIndex((button) => button.value === this.value);
359+
const current = buttons[index];
360+
const indicatorEl = this.getIndicator(current);
361+
362+
const { scrollDirection, scrollDistance } = ev.detail;
363+
364+
// Transform the indicator element to match the scroll of the segment view.
365+
if (indicatorEl) {
366+
indicatorEl.style.transition = 'transform 0.3s ease-out';
367+
368+
// Get dimensions of the segment and the button
369+
const segmentRect = segmentEl.getBoundingClientRect();
370+
const buttonRect = current.getBoundingClientRect();
371+
372+
// Calculate the potential transform value based on scroll direction
373+
const transformValue = scrollDirection === 'left' ? -scrollDistance : scrollDistance;
374+
375+
// Calculate the max allowed transformation (indicator should not move beyond the segment boundaries)
376+
const maxTransform = segmentRect.width - buttonRect.width;
377+
const minTransform = 0;
378+
379+
// Clamp the transform value to ensure it doesn't go out of bounds
380+
const clampedTransform = Math.max(minTransform, Math.min(transformValue, maxTransform));
381+
382+
// Apply the clamped transform value to the indicator element
383+
indicatorEl.style.transform = `translate3d(${clampedTransform}px, 0, 0)`;
384+
}
385+
}
386+
}
387+
326388
/**
327389
* Finds the related segment view and sets its current content
328390
* based on the selected segment button. This method

packages/angular/src/directives/proxies.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2043,11 +2043,15 @@ export class IonSegmentView {
20432043
constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) {
20442044
c.detach();
20452045
this.el = r.nativeElement;
2046+
proxyOutputs(this, this.el, ['ionSegmentViewScroll']);
20462047
}
20472048
}
20482049

20492050

2050-
export declare interface IonSegmentView extends Components.IonSegmentView {}
2051+
export declare interface IonSegmentView extends Components.IonSegmentView {
2052+
2053+
ionSegmentViewScroll: EventEmitter<CustomEvent<{ scrollDirection: string; scrollDistance: number }>>;
2054+
}
20512055

20522056

20532057
@ProxyCmp({

packages/angular/standalone/src/directives/proxies.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1882,11 +1882,15 @@ export class IonSegmentView {
18821882
constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) {
18831883
c.detach();
18841884
this.el = r.nativeElement;
1885+
proxyOutputs(this, this.el, ['ionSegmentViewScroll']);
18851886
}
18861887
}
18871888

18881889

1889-
export declare interface IonSegmentView extends Components.IonSegmentView {}
1890+
export declare interface IonSegmentView extends Components.IonSegmentView {
1891+
1892+
ionSegmentViewScroll: EventEmitter<CustomEvent<{ scrollDirection: string; scrollDistance: number }>>;
1893+
}
18901894

18911895

18921896
@ProxyCmp({

packages/vue/src/proxies.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -761,7 +761,8 @@ export const IonSegmentContent = /*@__PURE__*/ defineContainer<JSX.IonSegmentCon
761761

762762

763763
export const IonSegmentView = /*@__PURE__*/ defineContainer<JSX.IonSegmentView>('ion-segment-view', defineIonSegmentView, [
764-
'disabled'
764+
'disabled',
765+
'ionSegmentViewScroll'
765766
]);
766767

767768

0 commit comments

Comments
 (0)