• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

atinc / ngx-tethys / 68ef226c-f83e-44c1-b8ed-e420a83c5d84

28 May 2025 10:31AM UTC coverage: 10.352% (-80.0%) from 90.316%
68ef226c-f83e-44c1-b8ed-e420a83c5d84

Pull #3460

circleci

pubuzhixing8
chore: xxx
Pull Request #3460: refactor(icon): migrate signal input #TINFR-1476

132 of 6823 branches covered (1.93%)

Branch coverage included in aggregate %.

10 of 14 new or added lines in 1 file covered. (71.43%)

11648 existing lines in 344 files now uncovered.

2078 of 14525 relevant lines covered (14.31%)

6.69 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

1.66
/src/slider/slider.component.ts
1
import { NgStyle } from '@angular/common';
2
import {
3
    AfterViewInit,
4
    ChangeDetectorRef,
5
    Component,
6
    ElementRef,
7
    Input,
8
    NgZone,
9
    OnChanges,
10
    OnDestroy,
11
    OnInit,
12
    SimpleChanges,
13
    WritableSignal,
14
    effect,
15
    forwardRef,
16
    inject,
17
    input,
1✔
18
    numberAttribute,
UNCOV
19
    output,
×
UNCOV
20
    signal,
×
21
    viewChild
22
} from '@angular/core';
UNCOV
23
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
×
24
import { useHostRenderer } from '@tethys/cdk/dom';
25
import { TabIndexDisabledControlValueAccessorMixin } from 'ngx-tethys/core';
UNCOV
26
import { clamp, coerceBooleanProperty } from 'ngx-tethys/util';
×
UNCOV
27
import { Observable, Subscription, fromEvent } from 'rxjs';
×
UNCOV
28
import { distinctUntilChanged, map, pluck, takeUntil, tap } from 'rxjs/operators';
×
UNCOV
29

×
UNCOV
30
export type ThySliderType = 'primary' | 'success' | 'info' | 'warning' | 'danger';
×
UNCOV
31

×
UNCOV
32
export type ThySliderSize = 'sm' | 'md' | 'lg';
×
UNCOV
33

×
UNCOV
34
/**
×
UNCOV
35
 * 滑动输入条组件
×
UNCOV
36
 * @name thy-slider
×
UNCOV
37
 * @order 10
×
UNCOV
38
 */
×
UNCOV
39
@Component({
×
UNCOV
40
    selector: 'thy-slider',
×
UNCOV
41
    templateUrl: './slider.component.html',
×
UNCOV
42
    providers: [
×
UNCOV
43
        {
×
UNCOV
44
            provide: NG_VALUE_ACCESSOR,
×
UNCOV
45
            useExisting: forwardRef(() => ThySlider),
×
UNCOV
46
            multi: true
×
UNCOV
47
        }
×
UNCOV
48
    ],
×
UNCOV
49
    host: {
×
UNCOV
50
        '[attr.tabindex]': `tabIndex`,
×
UNCOV
51
        '[class.slider-vertical]': 'thyVertical()',
×
UNCOV
52
        '[class.slider-disabled]': '!!disabled',
×
53
        '[class.thy-slider]': 'true',
54
        '[class.cursor-pointer]': 'true'
UNCOV
55
    },
×
UNCOV
56
    imports: [NgStyle]
×
UNCOV
57
})
×
UNCOV
58
export class ThySlider
×
UNCOV
59
    extends TabIndexDisabledControlValueAccessorMixin
×
UNCOV
60
    implements OnInit, AfterViewInit, OnDestroy, OnChanges, ControlValueAccessor
×
61
{
62
    private cdr = inject(ChangeDetectorRef);
63
    private ngZone = inject(NgZone);
64
    private ref = inject(ElementRef);
UNCOV
65

×
UNCOV
66
    /**
×
UNCOV
67
     * 是否切换为纵轴模式
×
68
     */
UNCOV
69
    readonly thyVertical = input(false, { transform: coerceBooleanProperty });
×
UNCOV
70

×
UNCOV
71
    /**
×
72
     * 是否禁用
73
     */
74
    @Input({ transform: coerceBooleanProperty })
UNCOV
75
    override set thyDisabled(value: boolean) {
×
UNCOV
76
        this.disabled = value;
×
77
        this.toggleDisabled();
78
    }
UNCOV
79
    override get thyDisabled(): boolean {
×
80
        return this.disabled;
81
    }
UNCOV
82

×
83
    disabled = false;
84

UNCOV
85
    readonly sliderRail = viewChild<ElementRef>('sliderRail');
×
86

87
    readonly sliderTrack = viewChild<ElementRef>('sliderTrack');
UNCOV
88

×
UNCOV
89
    readonly sliderPointer = viewChild<ElementRef>('sliderPointer');
×
UNCOV
90

×
UNCOV
91
    private typeClassName: WritableSignal<string> = signal('');
×
92

93
    private sizeClassName: WritableSignal<string> = signal('');
94

95
    /**
UNCOV
96
     * 最大值
×
97
     */
98
    readonly thyMax = input<number, unknown>(100, { transform: numberAttribute });
UNCOV
99

×
UNCOV
100
    /**
×
101
     * 最小值
102
     */
UNCOV
103
    readonly thyMin = input<number, unknown>(0, { transform: numberAttribute });
×
104

105
    /**
106
     * 步长,需要被 thyMax - thyMin 的差值整除。
UNCOV
107
     */
×
UNCOV
108
    readonly thyStep = input<number, unknown>(1, { transform: numberAttribute });
×
UNCOV
109

×
110
    /**
UNCOV
111
     * 切换主题类型
×
112
     * @type primary | success | info | warning | danger
113
     */
UNCOV
114
    readonly thyType = input<ThySliderType>('success');
×
UNCOV
115

×
116
    /**
UNCOV
117
     * 通过变量设置颜色
×
118
     */
119
    readonly thyColor = input<string>();
UNCOV
120

×
121
    /**
122
     * 滑动输入条大小
UNCOV
123
     * @type sm | md | lg
×
UNCOV
124
     */
×
UNCOV
125
    readonly thySize = input<ThySliderSize>('sm');
×
126

127
    /**
UNCOV
128
     * 移动结束后的回调,参数为当前值
×
129
     */
130
    readonly thyAfterChange = output<{ value: number }>();
UNCOV
131

×
UNCOV
132
    public value: number;
×
UNCOV
133

×
UNCOV
134
    private dragStartListener: Observable<number>;
×
135

136
    private dragMoveListener: Observable<number>;
×
UNCOV
137

×
UNCOV
138
    private dragEndListener: Observable<Event>;
×
UNCOV
139

×
140
    private dragStartHandler: Subscription | null;
UNCOV
141

×
UNCOV
142
    private dragMoveHandler: Subscription | null;
×
UNCOV
143

×
144
    private dragEndHandler: Subscription | null;
UNCOV
145

×
UNCOV
146
    private hostRenderer = useHostRenderer();
×
UNCOV
147

×
148
    private onChangeCallback = (v: any) => {};
149

150
    private onTouchedCallback = (v: any) => {};
×
UNCOV
151

×
UNCOV
152
    constructor() {
×
153
        super();
UNCOV
154
        effect(() => {
×
UNCOV
155
            if (this.thyType()) {
×
156
                const typeName = `thy-slider-${this.thyType() || 'success'}`;
UNCOV
157
                this.typeClassName() && this.hostRenderer.removeClass(this.typeClassName());
×
UNCOV
158
                this.hostRenderer.addClass(typeName);
×
159
                this.typeClassName.set(typeName);
160
            }
161
        });
UNCOV
162

×
UNCOV
163
        effect(() => {
×
164
            if (this.thySize()) {
165
                const sizeName = `thy-slider-${this.thySize() || 'sm'}`;
UNCOV
166
                this.sizeClassName() && this.hostRenderer.removeClass(this.sizeClassName());
×
UNCOV
167
                this.hostRenderer.addClass(sizeName);
×
168
                this.sizeClassName.set(sizeName);
169
            }
UNCOV
170
        });
×
UNCOV
171
    }
×
UNCOV
172

×
173
    ngOnInit() {
174
        if (typeof ngDevMode === 'undefined' || ngDevMode) {
UNCOV
175
            verifyMinAndMax(this);
×
UNCOV
176
            verifyStepValues(this);
×
177
        }
178

UNCOV
179
        this.toggleDisabled();
×
180
        if (this.value === null || this.value === undefined) {
181
            this.setValue(this.ensureValueInRange(null));
182
        }
UNCOV
183
    }
×
UNCOV
184

×
UNCOV
185
    ngAfterViewInit() {
×
186
        this.registerMouseEventsListeners();
UNCOV
187
        this.toggleDisabled();
×
UNCOV
188
    }
×
189

UNCOV
190
    writeValue(newValue: number) {
×
UNCOV
191
        this.setValue(this.ensureValueInRange(newValue));
×
UNCOV
192
    }
×
UNCOV
193

×
UNCOV
194
    registerOnChange(fn: any) {
×
195
        this.onChangeCallback = fn;
196
    }
197

UNCOV
198
    registerOnTouched(fn: any) {
×
UNCOV
199
        this.onTouchedCallback = fn;
×
UNCOV
200
    }
×
UNCOV
201

×
202
    ngOnChanges(changes: SimpleChanges) {
UNCOV
203
        if (typeof ngDevMode === 'undefined' || ngDevMode) {
×
UNCOV
204
            if (changes.hasOwnProperty('thyMin') || changes.hasOwnProperty('thyMax') || changes.hasOwnProperty('thyStep')) {
×
UNCOV
205
                verifyMinAndMax(this);
×
206
                verifyStepValues(this);
207
            }
UNCOV
208
        }
×
UNCOV
209
    }
×
UNCOV
210

×
UNCOV
211
    ngOnDestroy() {
×
212
        this.unsubscribeMouseListeners();
213
    }
UNCOV
214

×
UNCOV
215
    private toggleDisabled() {
×
216
        if (this.thyDisabled) {
217
            this.unsubscribeMouseListeners();
UNCOV
218
        } else {
×
UNCOV
219
            this.subscribeMouseListeners(['start']);
×
220
        }
UNCOV
221
    }
×
222

223
    private setValue(value: number) {
UNCOV
224
        if (this.value !== value) {
×
UNCOV
225
            this.value = value;
×
UNCOV
226
            this.updateTrackAndPointer();
×
UNCOV
227
        }
×
228
        this.onChangeCallback(this.value);
UNCOV
229
    }
×
UNCOV
230

×
231
    private ensureValueInRange(value: number): number {
232
        if (!this.valueMustBeValid(value)) {
UNCOV
233
            return this.thyMin();
×
234
        }
UNCOV
235
        return clamp(value, this.thyMin(), this.thyMax());
×
236
    }
237

UNCOV
238
    private valueMustBeValid(value: number): boolean {
×
UNCOV
239
        return !isNaN(typeof value !== 'number' ? parseFloat(value) : value);
×
UNCOV
240
    }
×
241

242
    private updateTrackAndPointer() {
1✔
243
        const offset = this.valueToOffset(this.value);
1✔
244
        this.updateStyle(offset / 100);
245
        this.cdr.markForCheck();
246
    }
247

248
    private valueToOffset(value: number): number {
249
        return ((value - this.thyMin()) * 100) / (this.thyMax() - this.thyMin());
250
    }
251

252
    private updateStyle(offsetPercentage: number) {
253
        const percentage = Math.min(1, Math.max(0, offsetPercentage));
254
        const orientFields: string[] = this.thyVertical() ? ['height', 'bottom'] : ['width', 'left'];
255
        this.sliderTrack().nativeElement.style[orientFields[0]] = `${percentage * 100}%`;
256
        this.sliderPointer().nativeElement.style[orientFields[1]] = `${percentage * 100}%`;
257
    }
258

1✔
259
    private unsubscribeMouseListeners(actions: string[] = ['start', 'move', 'end']) {
260
        if (actions.includes('start') && this.dragStartHandler) {
261
            this.dragStartHandler.unsubscribe();
262
            this.dragStartHandler = null;
263
        }
264
        if (actions.includes('move') && this.dragMoveHandler) {
UNCOV
265
            this.dragMoveHandler.unsubscribe();
×
266
            this.dragMoveHandler = null;
267
        }
268
        if (actions.includes('end') && this.dragEndHandler) {
269
            this.dragEndHandler.unsubscribe();
270
            this.dragEndHandler = null;
271
        }
272
    }
273

274
    private subscribeMouseListeners(actions: string[] = ['start', 'move', 'end']) {
275
        if (actions.includes('start') && this.dragStartListener && !this.dragStartHandler) {
276
            this.dragStartHandler = this.dragStartListener.subscribe(this.mouseStartMoving.bind(this));
277
        }
278

279
        if (actions.includes('move') && this.dragMoveListener && !this.dragMoveHandler) {
280
            this.dragMoveHandler = this.dragMoveListener.subscribe(this.mouseMoving.bind(this));
281
        }
282

283
        if (actions.includes('end') && this.dragEndListener && !this.dragEndHandler) {
UNCOV
284
            this.dragEndHandler = this.dragEndListener.subscribe(this.mouseStopMoving.bind(this));
×
UNCOV
285
        }
×
286
    }
287

288
    private mouseStartMoving(value: number) {
UNCOV
289
        this.pointerController(true);
×
UNCOV
290
        this.setValue(value);
×
UNCOV
291
    }
×
292

UNCOV
293
    private mouseMoving(value: number) {
×
UNCOV
294
        this.setValue(this.ensureValueInRange(value));
×
295
        this.cdr.markForCheck();
296
    }
297

298
    private mouseStopMoving(): void {
299
        this.pointerController(false);
300
        this.cdr.markForCheck();
301
        this.thyAfterChange.emit({ value: this.value });
302
    }
303

304
    private pointerController(movable: boolean) {
305
        if (movable) {
306
            this.subscribeMouseListeners(['move', 'end']);
307
        } else {
308
            this.unsubscribeMouseListeners(['move', 'end']);
309
        }
310
    }
311
    private registerMouseEventsListeners() {
312
        const orientField = this.thyVertical() ? 'pageY' : 'pageX';
313

314
        this.dragStartListener = this.ngZone.runOutsideAngular(() => {
315
            return fromEvent(this.ref.nativeElement, 'mousedown').pipe(
316
                pluck(orientField),
317
                map((position: number, index) => this.mousePositionToAdaptiveValue(position))
318
            );
319
        });
320

321
        this.dragEndListener = this.ngZone.runOutsideAngular(() => {
322
            return fromEvent(document, 'mouseup');
323
        });
324

325
        this.dragMoveListener = this.ngZone.runOutsideAngular(() => {
326
            return fromEvent(document, 'mousemove').pipe(
327
                tap((e: Event) => {
328
                    e.stopPropagation();
329
                    e.preventDefault();
330
                }),
331
                pluck(orientField),
332
                distinctUntilChanged(),
333
                map((position: number) => this.mousePositionToAdaptiveValue(position)),
334
                distinctUntilChanged(),
335
                takeUntil(this.dragEndListener)
336
            );
337
        });
338
    }
339

340
    private mousePositionToAdaptiveValue(position: number): number {
341
        const sliderStartPosition = this.getSliderPagePosition();
342
        const sliderLength = this.getRailLength();
343
        if (!sliderLength) {
344
            return this.value;
345
        }
346
        const ratio = this.convertPointerPositionToRatio(position, sliderStartPosition, sliderLength);
347
        const value = this.ratioToValue(ratio);
348
        return parseFloat(value.toFixed(this.getDecimals(this.thyStep())));
349
    }
350

351
    private getSliderPagePosition(): number {
352
        const rect = this.ref.nativeElement.getBoundingClientRect();
353
        const window = this.ref.nativeElement.ownerDocument.defaultView;
354
        const orientFields: string[] = this.thyVertical() ? ['bottom', 'pageYOffset'] : ['left', 'pageXOffset'];
355
        return rect[orientFields[0]] + window[orientFields[1]];
356
    }
357

358
    private getRailLength() {
359
        const orientFiled = this.thyVertical() ? 'clientHeight' : 'clientWidth';
360

361
        return this.sliderRail().nativeElement[orientFiled];
362
    }
363

364
    private convertPointerPositionToRatio(pointerPosition: number, startPosition: number, totalLength: number) {
365
        if (this.thyVertical()) {
366
            return clamp((startPosition - pointerPosition) / totalLength, 0, 1);
367
        }
368
        return clamp((pointerPosition - startPosition) / totalLength, 0, 1);
369
    }
370

371
    private ratioToValue(ratio: number) {
372
        let value = (this.thyMax() - this.thyMin()) * ratio + this.thyMin();
373
        const step = this.thyStep();
374
        if (ratio === 0) {
375
            value = this.thyMin();
376
        } else if (ratio === 1) {
377
            value = this.thyMax();
378
        } else {
379
            value = Math.round(value / step) * step;
380
        }
381
        return clamp(value, this.thyMin(), this.thyMax());
382
    }
383

384
    private getDecimals(value: number): number {
385
        const valueString = value.toString();
386
        const integerLength = valueString.indexOf('.') + 1;
387
        return integerLength >= 0 ? valueString.length - integerLength : 0;
388
    }
389
}
390

391
// Note: keep `verifyMinAndMax` and `verifyStepValues` as separate functions (not as class properties)
392
// so they're tree-shakable in production mode.
393

394
function verifyMinAndMax(ctx: ThySlider): void | never {
395
    if (ctx.thyMin() >= ctx.thyMax()) {
396
        throw new Error('min value must less than max value.');
397
    }
398
}
399

400
function verifyStepValues(ctx: ThySlider): void | never {
401
    const thyStep = ctx.thyStep();
402
    if (ctx.thyStep() <= 0 || !thyStep) {
403
        throw new Error('step value must be greater than 0.');
404
    } else if (Number.isInteger(thyStep) && (ctx.thyMax() - ctx.thyMin()) % thyStep) {
405
        throw new Error('(max - min) must be divisible by step.');
406
    }
407
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc