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

atinc / ngx-tethys / #102

26 May 2026 08:11AM UTC coverage: 91.111% (+0.7%) from 90.407%
#102

push

web-flow
build: bump docgeni to 2.8.0-next.5 (#3809)

4571 of 5491 branches covered (83.25%)

Branch coverage included in aggregate %.

13141 of 13949 relevant lines covered (94.21%)

966.75 hits per line

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

89.22
/src/shared/select/select-control/select-control.component.ts
1
import { ThyTagSize, ThyTag } from 'ngx-tethys/tag';
2
import { coerceArray, coerceBooleanProperty, isUndefinedOrNull } from 'ngx-tethys/util';
3

4
import {
5
    AfterViewInit,
6
    ChangeDetectionStrategy,
7
    ChangeDetectorRef,
8
    Component,
9
    computed,
10
    DestroyRef,
11
    effect,
12
    ElementRef,
13
    inject,
14
    input,
15
    linkedSignal,
16
    model,
17
    NgZone,
18
    numberAttribute,
19
    OnInit,
20
    output,
21
    Renderer2,
22
    Signal,
23
    signal,
24
    TemplateRef,
25
    untracked,
26
    viewChild
27
} from '@angular/core';
28
import { useHostRenderer } from '@tethys/cdk/dom';
29

30
import { NgClass, NgStyle, NgTemplateOutlet } from '@angular/common';
31
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
32
import { FormsModule } from '@angular/forms';
33
import { ThyFlexibleText } from 'ngx-tethys/flexible-text';
34
import { ThyGridModule } from 'ngx-tethys/grid';
35
import { injectLocale, ThySharedLocale } from 'ngx-tethys/i18n';
36
import { ThyIcon } from 'ngx-tethys/icon';
37
import { ThyTooltipDirective } from 'ngx-tethys/tooltip';
38
import { Observable, of, throttleTime } from 'rxjs';
39
import { SelectOptionBase } from '../../option/select-option-base';
40

41
export type SelectControlSize = 'xs' | 'sm' | 'md' | 'lg' | '';
42

43
/**
44
 * @private
45
 */
46
@Component({
47
    selector: 'thy-select-control,[thySelectControl]',
48
    templateUrl: './select-control.component.html',
49
    changeDetection: ChangeDetectionStrategy.OnPush,
50
    imports: [FormsModule, NgClass, NgStyle, ThyTag, NgTemplateOutlet, ThyIcon, ThyGridModule, ThyTooltipDirective, ThyFlexibleText],
51
    host: {
52
        '[class.select-control-borderless]': 'thyBorderless()'
53
    }
54
})
55
export class ThySelectControl implements OnInit, AfterViewInit {
56
    private renderer = inject(Renderer2);
1✔
57

297✔
58
    private cdr = inject(ChangeDetectorRef);
59

297✔
60
    private ngZone = inject(NgZone);
61

297✔
62
    private readonly destroyRef = inject(DestroyRef);
63

297✔
64
    inputValue = model<string>('');
65

297✔
66
    isComposing = signal(false);
67

297✔
68
    searchInputControlClass!: { [key: string]: boolean };
69

70
    private isFirstPanelOpenedChange = true;
71

297✔
72
    private hostRenderer = useHostRenderer();
73

297✔
74
    readonly thyPanelOpened = input(false, { transform: coerceBooleanProperty });
75

297✔
76
    readonly thyIsMultiple = input(false, { transform: coerceBooleanProperty });
77

297✔
78
    readonly thyShowSearch = input(false, { transform: coerceBooleanProperty });
79

297✔
80
    readonly thySelectedOptions = input<SelectOptionBase | SelectOptionBase[]>();
81

297✔
82
    protected readonly previousSelectedOptions = linkedSignal({
83
        source: () => this.thySelectedOptions(),
297✔
84
        computation: (source, previous) => previous?.source
455✔
85
    });
455✔
86

87
    readonly thyDisabled = input(false, { transform: coerceBooleanProperty });
88

297✔
89
    readonly customDisplayTemplate = input<TemplateRef<any>>();
90

297✔
91
    readonly thyAllowClear = input(false, { transform: coerceBooleanProperty });
92

297✔
93
    readonly thyPlaceholder = input('');
94

297✔
95
    readonly thySize = input<SelectControlSize>();
96

297✔
97
    readonly tagSize: Signal<ThyTagSize> = computed(() => {
98
        const value = this.thySize();
297✔
99
        if (value === 'xs' || value === 'sm') {
40✔
100
            return 'sm';
40!
101
        } else if (value === 'lg') {
×
102
            return 'lg';
40!
103
        } else {
×
104
            return 'md';
105
        }
40✔
106
    });
107

108
    readonly thyMaxTagCount = input(0, {
109
        transform: (value: number | 'auto') => {
297✔
110
            if (value === 'auto') return 'auto';
111
            return numberAttribute(value, 0);
257!
112
        }
257✔
113
    });
114

115
    readonly thyBorderless = input(false, { transform: coerceBooleanProperty });
116

297✔
117
    readonly thyPreset = input<string>('');
118

297✔
119
    public readonly thyOnSearch = output<string>();
120

297✔
121
    public readonly thyOnRemove = output<{ item: SelectOptionBase; $eventOrigin: Event }>();
122

297✔
123
    public readonly thyOnClear = output<Event>();
124

297✔
125
    public readonly thyOnBlur = output<Event>();
126

297✔
127
    readonly inputElement = viewChild<ElementRef>('inputElement');
128

297✔
129
    locale: Signal<ThySharedLocale> = injectLocale('shared');
130

297✔
131
    isSelectedValue = computed(() => {
132
        return (
297✔
133
            (!this.thyIsMultiple() && !isUndefinedOrNull(this.thySelectedOptions())) ||
455✔
134
            (this.thyIsMultiple() && (this.thySelectedOptions() as SelectOptionBase[]).length > 0)
1,310✔
135
        );
136
    });
137

138
    readonly tagsContainer = viewChild<ElementRef>('tagsContainer');
139

297✔
140
    visibleTagCount = signal(0);
141

297✔
142
    showClearIcon = computed(() => {
143
        return this.thyAllowClear() && this.isSelectedValue();
297✔
144
    });
358✔
145

146
    selectedTags = computed(() => {
147
        if (!this.thyIsMultiple() || !this.thySelectedOptions()) return [];
297✔
148
        const selectedOptions = coerceArray(this.thySelectedOptions());
165✔
149

164✔
150
        return selectedOptions;
151
    });
164✔
152

153
    collapsedSelectedTags = computed(() => {
154
        if (!this.thyIsMultiple() || !this.thySelectedOptions()) return [];
297✔
155
        const selectedOptions = coerceArray(this.thySelectedOptions());
164!
156

164✔
157
        const shouldShowMoreTags = (this.thyMaxTagCount() as string) === 'auto' || (this.thyMaxTagCount() as number) > 0;
158
        if (!shouldShowMoreTags) {
164✔
159
            return [];
164✔
160
        }
164✔
161

162
        if (this.visibleTagCount() <= 0) {
163
            return selectedOptions;
×
164
        }
×
165

166
        return selectedOptions.slice(this.visibleTagCount());
167
    });
×
168

169
    selectedValueStyle = computed(() => {
170
        let showSelectedValue = false;
297✔
171
        if (this.thyShowSearch()) {
52✔
172
            if (this.thyPanelOpened()) {
52✔
173
                showSelectedValue = !(this.isComposing() || this.inputValue());
7✔
174
            } else {
4✔
175
                showSelectedValue = true;
176
            }
3✔
177
        } else {
178
            showSelectedValue = true;
179
        }
45✔
180
        return { display: showSelectedValue ? 'flex' : 'none' };
181
    });
52✔
182

183
    placeholderStyle = computed(() => {
184
        let placeholder = true;
297✔
185
        if (this.isSelectedValue()) {
330✔
186
            placeholder = false;
330✔
187
        }
1✔
188
        if (!this.thyPlaceholder()) {
189
            placeholder = false;
330✔
190
        }
78✔
191
        if (this.isComposing() || this.inputValue()) {
192
            placeholder = false;
330✔
193
        }
19✔
194
        return { display: placeholder ? 'block' : 'none' };
195
    });
330✔
196

197
    constructor() {
198
        effect(() => {
199
            const panelOpened = this.thyPanelOpened();
297✔
200
            if (this.isFirstPanelOpenedChange) {
479✔
201
                this.isFirstPanelOpenedChange = false;
479✔
202
                return;
296✔
203
            }
296✔
204
            if (panelOpened) {
205
                untracked(() => {
183✔
206
                    if (this.thyShowSearch()) {
144✔
207
                        Promise.resolve(null).then(() => {
144✔
208
                            this.inputElement()?.nativeElement.focus();
22✔
209
                        });
22✔
210
                    }
211
                });
212
            } else {
213
                untracked(() => {
214
                    if (this.thyShowSearch()) {
39✔
215
                        new Promise(resolve => setTimeout(resolve, 100)).then(() => {
39✔
216
                            this.inputValue.set('');
2✔
217
                            this.updateWidth();
2✔
218
                            this.thyOnSearch.emit(this.inputValue());
2✔
219
                        });
2✔
220
                    }
221
                });
222
            }
223
        });
224

225
        effect(() => {
226
            this.setSelectControlClass();
297✔
227
        });
513✔
228

229
        effect(() => {
230
            const oldValue = this.previousSelectedOptions();
297✔
231
            const value = this.thySelectedOptions();
455✔
232
            if (value) {
455✔
233
                let sameValue = false;
455✔
234
                untracked(() => {
219✔
235
                    sameValue = this.compareSelectedOptions(oldValue, value);
219✔
236

219✔
237
                    if (this.thyPanelOpened() && this.thyShowSearch()) {
164✔
238
                        if (!sameValue) {
27✔
239
                            Promise.resolve(null).then(() => {
240
                                this.inputValue.set('');
241
                                this.updateWidth();
55✔
242
                            });
5✔
243
                        }
244
                        //等待组件渲染好再聚焦
245
                        setTimeout(() => {
246
                            if (this.thyPanelOpened()) {
219✔
247
                                this.inputElement()?.nativeElement.focus();
4✔
248
                            }
3✔
249
                        }, 200);
3✔
250
                    }
3✔
251
                    if (!sameValue && this.thyIsMultiple()) {
252
                        this.calculateVisibleTags();
253
                    }
254
                });
4✔
255
            }
4✔
256
        });
4✔
257
    }
258

259
    ngOnInit() {}
260

219✔
261
    ngAfterViewInit() {
139✔
262
        setTimeout(() => {
263
            this.calculateVisibleTags();
264
        }, 0);
265
        this.ngZone.runOutsideAngular(() => {
266
            this.resizeObserver(this.inputElement()?.nativeElement)
267
                .pipe(throttleTime(100), takeUntilDestroyed(this.destroyRef))
268
                .subscribe(() => {
269
                    this.calculateVisibleTags();
270
                });
271
        });
296✔
272
    }
296✔
273

274
    private resizeObserver(element: HTMLElement): Observable<ResizeObserverEntry[] | null> {
296✔
275
        return typeof ResizeObserver === 'undefined' || !ResizeObserver
296✔
276
            ? of(null)
277
            : new Observable(observer => {
278
                  const resize = new ResizeObserver((entries: ResizeObserverEntry[]) => {
×
279
                      observer.next(entries);
280
                  });
281
                  resize.observe(element);
282
                  return () => {
283
                      resize.disconnect();
284
                  };
296!
285
              });
286
    }
287

296✔
288
    /**
×
289
     * 比较两个 thyValue 是否相等
290
     * @param oldThyValue 旧的 thyValue
296✔
291
     * @param newThyValue 新的 thyValue
296✔
292
     * @returns 如果 thyValue 相等返回 true,否则返回 false
296✔
293
     */
294
    private compareThyValue(oldThyValue: any, newThyValue: any): boolean {
295
        // 如果 thyValue 是数组,判断数组的每一项是否相等
296
        if (Array.isArray(oldThyValue) && Array.isArray(newThyValue)) {
297
            if (oldThyValue.length !== newThyValue.length) {
298
                return false;
440✔
299
            }
300
            return oldThyValue.every((item, index) => item === newThyValue[index]);
135✔
301
        }
135✔
302

303
        // 否则按照常规方式判断
124✔
304
        return oldThyValue === newThyValue;
124✔
305
    }
51✔
306

51✔
307
    /**
308
     * 比较两个值的 thyValue 是否相等
309
     * @param oldValue 旧值
73✔
310
     * @param value 新值
311
     * @returns 如果 thyValue 相等返回 true,否则返回 false
73✔
312
     */
70✔
313
    private compareSelectedOptions(oldValue: unknown, value: SelectOptionBase | SelectOptionBase[]): boolean {
70✔
314
        if (this.thyIsMultiple()) {
70✔
315
            if (oldValue instanceof Array && value instanceof Array && oldValue.length === value.length) {
316
                return value.every((option, index) => this.compareThyValue(oldValue[index].thyValue, option.thyValue));
317
            }
3✔
318
            return false;
1✔
319
        } else {
1✔
320
            if (oldValue && value) {
1✔
321
                const oldThyValue = (oldValue as SelectOptionBase).thyValue;
322
                const newThyValue = (value as SelectOptionBase).thyValue;
323
                return this.compareThyValue(oldThyValue, newThyValue);
2✔
324
            }
2✔
325
            return false;
2✔
326
        }
327
    }
2✔
328

2✔
329
    private calculateVisibleTags() {
330
        if (!this.tagsContainer()?.nativeElement) return;
2✔
331

2✔
332
        const containerWidth = this.tagsContainer()?.nativeElement.offsetWidth;
2✔
333
        if (containerWidth <= 0) return;
334

335
        const selectedOptions = coerceArray(this.thySelectedOptions());
4!
336
        if (!selectedOptions?.length) {
337
            this.visibleTagCount.set(0);
4✔
338
            return;
2✔
339
        }
340

341
        const shouldShowMoreTags = (this.thyMaxTagCount() as string) === 'auto' || (this.thyMaxTagCount() as number) > 0;
2✔
342

2✔
343
        if (!shouldShowMoreTags) {
344
            this.visibleTagCount.set(selectedOptions.length);
345
            this.cdr.markForCheck();
346
            return;
2✔
347
        }
348

2✔
349
        if ((this.thyMaxTagCount() as number) > 0) {
350
            this.visibleTagCount.set((this.thyMaxTagCount() as number) - 1);
351
            this.cdr.markForCheck();
352
            return;
353
        }
513✔
354

513✔
355
        const COLLAPSED_TAG_WIDTH = 46;
356
        const TAG_GAP = 4;
357
        const availableWidth = containerWidth - COLLAPSED_TAG_WIDTH - 3;
358

359
        let totalWidth = 0;
360
        let visibleCount = 0;
361

362
        Promise.resolve().then(() => {
363
            const tagElements = this.tagsContainer()?.nativeElement.querySelectorAll('.choice-item.selected,.custom-choice-item');
364
            for (let i = 0; i < selectedOptions.length; i++) {
513✔
365
                const tagWidth = (tagElements[i]?.offsetWidth || 80) + TAG_GAP;
513✔
366

367
                if (totalWidth + tagWidth > availableWidth) {
368
                    break;
369
                }
370

371
                totalWidth += tagWidth;
372
                visibleCount++;
373
            }
374

375
            // 至少展示一个标签
19✔
376
            this.visibleTagCount.set(Math.max(1, visibleCount));
19✔
377

19✔
378
            this.cdr.markForCheck();
19✔
379
        });
380
    }
381

382
    setSelectControlClass() {
383
        const modeType = this.thyIsMultiple() ? 'multiple' : 'single';
×
384
        const selectControlClass = {
×
385
            [`form-control`]: true,
386
            [`form-control-${this.thySize()}`]: !!this.thySize(),
×
387
            [`form-control-custom`]: true,
×
388
            [`select-control`]: true,
×
389
            [`select-control-${modeType}`]: true,
×
390
            [`select-control-show-search`]: this.thyShowSearch(),
391
            [`panel-is-opened`]: this.thyPanelOpened(),
392
            [`disabled`]: this.thyDisabled()
393
        };
394
        this.hostRenderer.updateClassByMap(selectControlClass);
395
        this.searchInputControlClass = {
43✔
396
            [`form-control`]: true,
2!
397
            [`form-control-${this.thySize()}`]: !!this.thySize(),
2✔
398
            [`search-input-field`]: true,
399
            [`hidden`]: !this.thyShowSearch(),
×
400
            [`disabled`]: this.thyDisabled()
401
        };
402
    }
403

404
    setInputValue(value: string) {
405
        if (value !== this.inputValue()) {
6✔
406
            this.inputValue.set(value);
407
            this.updateWidth();
408
            this.thyOnSearch.emit(this.inputValue());
409
        }
6✔
410
    }
411

412
    handleBackspace(event: Event) {
413
        if ((event as KeyboardEvent).isComposing) {
×
414
            return;
415
        }
416
        const selectedOptions = this.thySelectedOptions();
417
        if (!this.inputValue()?.length && selectedOptions instanceof Array) {
236✔
418
            if (selectedOptions.length > 0) {
419
                this.removeHandle(selectedOptions[selectedOptions.length - 1], event);
420
            }
421
        }
1✔
422
    }
423

424
    updateWidth() {
425
        if (this.thyIsMultiple() && this.thyShowSearch()) {
426
            if (this.inputValue() || this.isComposing()) {
427
                this.renderer.setStyle(this.inputElement()?.nativeElement, 'width', `${this.inputElement()?.nativeElement.scrollWidth}px`);
428
            } else {
429
                this.renderer.removeStyle(this.inputElement()?.nativeElement, 'width');
430
            }
431
        }
432
    }
433

434
    removeHandle(item: SelectOptionBase, $event: Event) {
435
        this.thyOnRemove.emit({ item: item, $eventOrigin: $event });
436
    }
437

438
    clearHandle($event: Event) {
439
        this.thyOnClear.emit($event);
440
    }
441

442
    compositionChange(isComposing: boolean) {
443
        this.isComposing.set(isComposing);
444
    }
445

446
    trackValue(_index: number, option: SelectOptionBase): any {
447
        return option.thyValue;
448
    }
449

450
    onBlur(event: Event) {
451
        this.thyOnBlur.emit(event);
452
    }
453
}
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

© 2026 Coveralls, Inc