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

atinc / ngx-tethys / #88

11 Aug 2025 06:37AM UTC coverage: 90.32% (+0.01%) from 90.309%
#88

push

web-flow
refactor(select): migrate to signal for select #TINFR-1767 (#3514)

5535 of 6815 branches covered (81.22%)

Branch coverage included in aggregate %.

57 of 58 new or added lines in 2 files covered. (98.28%)

19 existing lines in 2 files now uncovered.

13863 of 14662 relevant lines covered (94.55%)

902.81 hits per line

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

92.66
/src/select/custom-select/custom-select.component.ts
1
import {
2
    getFlexiblePositions,
3
    injectPanelEmptyIcon,
4
    scaleMotion,
5
    scaleXMotion,
6
    scaleYMotion,
7
    ScrollToService,
8
    TabIndexDisabledControlValueAccessorMixin,
9
    ThyClickDispatcher,
10
    ThyPlacement
11
} from 'ngx-tethys/core';
12
import { ThyEmpty } from 'ngx-tethys/empty';
13
import { ThyLoading } from 'ngx-tethys/loading';
14
import {
15
    IThyOptionParentComponent,
16
    SelectControlSize,
17
    THY_OPTION_PARENT_COMPONENT,
18
    ThyOption,
19
    ThyOptionsContainer,
20
    ThyOptionSelectionChangeEvent,
1✔
21
    ThyScrollDirective,
1✔
22
    ThySelectControl,
1✔
23
    ThySelectOptionGroup,
1✔
24
    ThyStopPropagationDirective
1✔
25
} from 'ngx-tethys/shared';
1✔
26
import {
27
    A,
28
    coerceBooleanProperty,
29
    DOWN_ARROW,
30
    elementMatchClosest,
31
    END,
1✔
32
    ENTER,
33
    FunctionProp,
114✔
34
    hasModifierKey,
35
    helpers,
36
    HOME,
890✔
37
    isArray,
38
    isFunction,
39
    LEFT_ARROW,
17!
40
    RIGHT_ARROW,
17✔
41
    SPACE,
5✔
42
    UP_ARROW
43
} from 'ngx-tethys/util';
17✔
44
import { defer, merge, Observable, Subject, Subscription, timer } from 'rxjs';
45
import { distinctUntilChanged, filter, map, startWith, switchMap, take, takeUntil } from 'rxjs/operators';
46
import { ActiveDescendantKeyManager } from '@angular/cdk/a11y';
47
import { coerceElement } from '@angular/cdk/coercion';
350✔
48
import { SelectionModel } from '@angular/cdk/collections';
350✔
49
import { CdkConnectedOverlay, CdkOverlayOrigin, ConnectionPositionPair, Overlay, ScrollStrategy } from '@angular/cdk/overlay';
350✔
50
import { isPlatformBrowser, NgClass, NgTemplateOutlet } from '@angular/common';
16✔
51
import {
52
    AfterContentInit,
16✔
53
    AfterViewInit,
16✔
54
    ChangeDetectionStrategy,
16✔
55
    ChangeDetectorRef,
56
    Component,
57
    ContentChildren,
58
    ElementRef,
172✔
59
    forwardRef,
161✔
60
    HostListener,
61
    Input,
62
    NgZone,
11✔
63
    numberAttribute,
64
    OnDestroy,
65
    OnInit,
66
    output,
1,520✔
67
    PLATFORM_ID,
68
    QueryList,
69
    TemplateRef,
172✔
70
    ViewChild,
172✔
71
    viewChild,
172✔
72
    ViewChildren,
172✔
73
    inject,
172✔
74
    Signal,
172✔
75
    input,
172✔
76
    contentChild,
172✔
77
    effect,
172✔
78
    untracked,
172✔
79
    viewChildren,
172✔
80
    contentChildren
172✔
81
} from '@angular/core';
172✔
82
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
172✔
83

172✔
84
import {
172✔
85
    DEFAULT_SELECT_CONFIG,
172✔
86
    THY_SELECT_CONFIG,
172✔
87
    THY_SELECT_SCROLL_STRATEGY,
88
    ThyDropdownWidthMode,
89
    ThySelectConfig
90
} from '../select.config';
172✔
91
import { injectLocale, ThySelectLocale } from 'ngx-tethys/i18n';
172✔
92

172✔
93
export type SelectMode = 'multiple' | '';
179!
94

888✔
95
export type ThySelectTriggerType = 'click' | 'hover';
UNCOV
96

×
97
export const SELECT_PANEL_MAX_HEIGHT = 300;
98

172✔
99
export const SELECT_OPTION_MAX_HEIGHT = 40;
172✔
100

172✔
101
export const SELECT_OPTION_GROUP_MAX_HEIGHT = 30;
172✔
102

172✔
103
export const SELECT_PANEL_PADDING_TOP = 10;
172✔
104

172✔
105
export const THY_SELECT_PANEL_MIN_WIDTH = 200;
172✔
106

172✔
107
export interface OptionValue {
172✔
108
    thyLabelText?: string;
172✔
109
    thyValue?: string;
172✔
110
    thyDisabled?: boolean;
172✔
111
    thyShowOptionCustom?: boolean;
172!
112
    thySearchKey?: string;
172✔
113
}
172✔
114

172✔
115
export interface ThySelectOptionModel {
172✔
116
    value?: string | number;
172✔
117
    disabled?: boolean;
172✔
118
    label?: string;
172✔
119
    icon?: string;
172✔
120
    groupLabel?: string;
172✔
121
}
172✔
122

172✔
123
interface ThyOptionGroupModel extends ThySelectOptionModel {
172✔
124
    children?: ThySelectOptionModel[];
172✔
125
}
172✔
126

172✔
127
const noop = () => {};
128

4!
UNCOV
129
/**
×
130
 * 下拉选择组件
131
 * @name thy-select,thy-custom-select
4✔
132
 * @order 10
4✔
133
 */
4✔
134
@Component({
4✔
135
    selector: 'thy-select,thy-custom-select',
136
    templateUrl: './custom-select.component.html',
137
    exportAs: 'thySelect',
172✔
138
    providers: [
172✔
139
        {
172✔
140
            provide: THY_OPTION_PARENT_COMPONENT,
172✔
141
            useExisting: ThySelect
172✔
142
        },
172✔
143
        {
172✔
144
            provide: NG_VALUE_ACCESSOR,
172✔
145
            useExisting: forwardRef(() => ThySelect),
172✔
146
            multi: true
172✔
147
        }
179✔
148
    ],
179✔
149
    changeDetection: ChangeDetectionStrategy.OnPush,
179✔
150
    imports: [
179✔
151
        CdkOverlayOrigin,
179✔
152
        ThySelectControl,
153
        CdkConnectedOverlay,
154
        ThyStopPropagationDirective,
155
        NgClass,
156
        ThyScrollDirective,
247✔
157
        ThyLoading,
247✔
158
        ThyEmpty,
159
        ThyOptionsContainer,
160
        ThyOption,
188✔
161
        ThySelectOptionGroup,
188✔
162
        NgTemplateOutlet
188✔
163
    ],
171✔
164
    host: {
165
        '[class.thy-select-custom]': 'true',
188✔
166
        '[class.thy-select]': 'true',
188!
167
        '[class.menu-is-opened]': 'panelOpen',
188✔
168
        '[attr.tabindex]': 'tabIndex',
169
        '(focus)': 'onFocus($event)',
170
        '(blur)': 'onBlur($event)'
171
    },
198✔
172
    animations: [scaleXMotion, scaleYMotion, scaleMotion]
4✔
173
})
4✔
174
export class ThySelect
4✔
175
    extends TabIndexDisabledControlValueAccessorMixin
176
    implements ControlValueAccessor, IThyOptionParentComponent, OnInit, AfterViewInit, AfterContentInit, OnDestroy
177
{
178
    private ngZone = inject(NgZone);
179
    private elementRef = inject(ElementRef);
180
    private changeDetectorRef = inject(ChangeDetectorRef);
181
    private overlay = inject(Overlay);
1✔
182
    private thyClickDispatcher = inject(ThyClickDispatcher);
3✔
183
    private platformId = inject(PLATFORM_ID);
1✔
184
    private locale: Signal<ThySelectLocale> = injectLocale('select');
1✔
185
    scrollStrategyFactory = inject<FunctionProp<ScrollStrategy>>(THY_SELECT_SCROLL_STRATEGY, { optional: true })!;
3✔
186
    selectConfig = inject(THY_SELECT_CONFIG, { optional: true })!;
1✔
187
    emptyIcon: Signal<string> = injectPanelEmptyIcon();
188

189
    disabled = false;
190

1✔
191
    mode: SelectMode = '';
192

1✔
193
    scrollTop = 0;
3✔
194

2✔
195
    modalValue: any = null;
2✔
196

1✔
197
    defaultOffset = 4;
1✔
198

199
    dropDownClass: { [key: string]: boolean };
200

201
    dropDownMinWidth: number | null = null;
1✔
202

203
    /**
204
     * 设置下拉框的最小宽度,默认值 `match-select`,表示与输入框的宽度一致;`min-width` 表示最小宽度为200px;支持自定义最小宽度,比如传 `{minWidth: 150}` 表示最小宽度为150px
1✔
205
     * @default match-select
206
     */
207
    readonly thyDropdownWidthMode = input<ThyDropdownWidthMode>();
12✔
208

1✔
209
    public dropDownPositions: ConnectionPositionPair[];
210

211
    public selectionModel: SelectionModel<ThyOption>;
3✔
212

213
    public triggerRectWidth: number;
214

215
    public scrollStrategy: ScrollStrategy;
188✔
216

188✔
217
    private resizeSubscription: Subscription;
188✔
218

1✔
219
    private selectionModelSubscription: Subscription;
220

187✔
221
    /**
4✔
222
     * 手动聚焦中的标识
223
     */
224
    private manualFocusing = false;
183✔
225

226
    private config: ThySelectConfig;
188✔
227

228
    private readonly destroy$ = new Subject<void>();
229

171✔
230
    readonly optionSelectionChanges: Observable<ThyOptionSelectionChangeEvent> = defer(() => {
3✔
231
        if (this.options) {
232
            return merge(...this.options.map(option => option.selectionChange));
233
        }
234
        return this.ngZone.onStable.asObservable().pipe(
171✔
235
            take(1),
168✔
236
            switchMap(() => this.optionSelectionChanges)
237
        );
238
    }) as Observable<ThyOptionSelectionChangeEvent>;
239

171✔
240
    readonly cdkConnectedOverlay = viewChild<CdkConnectedOverlay>(CdkConnectedOverlay);
179✔
241

179✔
242
    keyManager: ActiveDescendantKeyManager<ThyOption>;
179✔
243

179✔
244
    panelOpen = false;
1✔
245

1✔
246
    /**
247
     * 搜索时回调
179✔
248
     */
179✔
249
    readonly thyOnSearch = output<string>();
250

251
    /**
252
     * 下拉菜单滚动到底部事件,可以用这个事件实现滚动加载
179✔
253
     */
42✔
254
    readonly thyOnScrollToBottom = output<void>();
255

256
    /**
257
     * 下拉菜单展开和折叠状态事件
171✔
258
     */
1✔
259
    readonly thyOnExpandStatusChange = output<boolean>();
1✔
260

1✔
261
    /**
1✔
262
     * 下拉列表是否显示搜索框
263
     */
264
    readonly thyShowSearch = input(false, { transform: coerceBooleanProperty });
265

266
    /**
249✔
267
     * 选择框默认文字
268
     */
269
    readonly thyPlaceHolder = input<string>(this.locale().placeholder);
80✔
270

271
    /**
272
     * 是否使用服务端搜索,当为 true 时,将不再在前端进行过滤
74✔
273
     */
64✔
274
    readonly thyServerSearch = input(false, { transform: coerceBooleanProperty });
62✔
275

62✔
276
    /**
277
     * 异步加载 loading 状态,false 表示加载中,true 表示加载完成
278
     */
2!
NEW
279
    readonly thyLoadState = input(true, { transform: coerceBooleanProperty });
×
UNCOV
280

×
281
    /**
282
     * 是否自动设置选项第一条为高亮状态
283
     */
284
    readonly thyAutoActiveFirstItem = input(true, { transform: coerceBooleanProperty });
285

286
    /**
287
     * 下拉选择模式
1!
288
     * @type 'multiple' | ''
1✔
289
     */
290
    readonly thyMode = input<SelectMode>('');
291

292
    /**
1✔
293
     * 操作图标类型
1!
294
     * @type primary | success | danger | warning
1✔
295
     * @default primary
1✔
296
     */
297
    readonly thySize = input<SelectControlSize>();
298

299
    /**
300
     * 数据为空时显示的提示文字
11✔
301
     */
11✔
302
    readonly thyEmptyStateText = input(this.locale().empty, { transform: (value: string) => value || this.locale().empty });
2✔
303

2✔
304
    /**
305
     * 搜索结果为空时显示的提示文字
306
     */
9✔
307
    readonly thyEmptySearchMessageText = input(this.locale().empty, { transform: (value: string) => value || this.locale().empty });
9✔
308

52✔
309
    /**
12✔
310
     * 滚动加载是否可用,只能当这个参数可以,下面的thyOnScrollToBottom事件才会触发
311
     */
312
    readonly thyEnableScrollLoad = input(false, { transform: coerceBooleanProperty });
40✔
313

314
    /**
315
     * 单选( thyMode="" 或者不设置)时,选择框支持清除
9✔
316
     */
9✔
317
    readonly thyAllowClear = input(false, { transform: coerceBooleanProperty });
318

319
    /**
320
     * 是否禁用
321
     * @default false
3✔
322
     */
1✔
323
    @Input({ transform: coerceBooleanProperty })
324
    set thyDisabled(value: boolean) {
2✔
325
        this.disabled = value;
326
    }
327
    get thyDisabled(): boolean {
328
        return this.disabled;
329
    }
24!
330

331
    /**
332
     * 排序比较函数
3✔
333
     */
3✔
334
    readonly thySortComparator = input<(a: ThyOption, b: ThyOption, options: ThyOption[]) => number>();
335

24✔
336
    /**
337
     * Footer 模板,默认值为空不显示 Footer
338
     */
28✔
339
    readonly thyFooterTemplate = input<TemplateRef<any>>();
28✔
340

28✔
341
    /**
342
     * 弹出位置
343
     * @type ThyPlacement
4✔
344
     */
4✔
345
    readonly thyPlacement = input<ThyPlacement>();
1✔
346

347
    /**
18✔
348
     * 自定义 Overlay Origin
1✔
349
     */
350
    readonly thyOrigin = input<ElementRef | HTMLElement>();
1✔
351

352
    /**
353
     * 自定义 Footer 模板容器 class
2✔
354
     */
355
    readonly thyFooterClass = input<string>('thy-custom-select-footer');
356

357
    /**
4✔
358
     * @private
3✔
359
     */
360
    readonly selectedValueDisplayRef = contentChild<TemplateRef<any>>('selectedDisplay');
4✔
361

1✔
362
    /**
363
     * 初始化时,是否展开面板
3✔
364
     */
3✔
365
    readonly thyAutoExpand = input(false, { transform: coerceBooleanProperty });
3✔
366

367
    /**
368
     * 是否弹出透明遮罩,如果显示遮罩则会阻止滚动区域滚动
56✔
369
     */
56✔
370
    readonly thyHasBackdrop = input(false, { transform: coerceBooleanProperty });
43✔
371

372
    /**
373
     * 设置多选时最大显示的标签数量,0 表示不限制
374
     */
375
    readonly thyMaxTagCount = input(0, { transform: numberAttribute });
450✔
376

377
    /**
378
     * 是否隐藏选择框边框
2,207✔
379
     */
380
    readonly thyBorderless = input(false, { transform: coerceBooleanProperty });
381

80✔
382
    isReactiveDriven = false;
383

384
    innerOptions: ThySelectOptionModel[];
1!
385

1✔
386
    optionGroups: ThyOptionGroupModel[] = [];
387

388
    /**
90✔
389
     * option 列表
10✔
390
     */
9✔
391
    readonly thyOptions = input(undefined, {
392
        transform: (value: ThySelectOptionModel[]) => {
393
            if (value === null) {
394
                value = [];
80✔
395
            }
396
            this.innerOptions = value;
397
            this.isReactiveDriven = true;
398
            this.buildReactiveOptions();
83✔
399
            return value;
2✔
400
        }
401
    });
81✔
402

81✔
403
    options: QueryList<ThyOption>;
81✔
404

81✔
405
    /**
81✔
406
     * 目前只支持多选选中项的展示,默认为空,渲染文字模板,传入tag,渲染展示模板,
81✔
407
     * @default ''|tag
408
     */
409
    readonly thyPreset = input<string>('');
57✔
410

30✔
411
    @ViewChild('trigger', { read: ElementRef, static: true }) trigger: ElementRef<HTMLElement>;
30✔
412

30✔
413
    readonly panel = viewChild<ElementRef<HTMLElement>>('panel');
30✔
414

30✔
415
    /**
416
     * @private
417
     */
418
    @ContentChildren(ThyOption, { descendants: true }) contentOptions: QueryList<ThyOption>;
47✔
419

47✔
420
    @ViewChildren(ThyOption) viewOptions: QueryList<ThyOption>;
66✔
421

422
    /**
47✔
423
     * @private
25✔
424
     */
425
    readonly contentGroups = contentChildren<ThySelectOptionGroup>(ThySelectOptionGroup);
426

22✔
427
    readonly viewGroups = viewChildren<ThySelectOptionGroup>(ThySelectOptionGroup);
2✔
428

429
    @HostListener('keydown', ['$event'])
430
    handleKeydown(event: KeyboardEvent): void {
20✔
431
        if (!this.disabled) {
432
            if (event.keyCode === ENTER) {
433
                event.stopPropagation();
47✔
434
            }
47✔
435
            this.panelOpen ? this.handleOpenKeydown(event) : this.handleClosedKeydown(event);
436
        }
81✔
437
    }
91!
438

91✔
439
    get optionsChanges$() {
81✔
440
        this.options = this.isReactiveDriven ? this.viewOptions : this.contentOptions;
5✔
441
        let previousOptions: ThyOption[] = this.options.toArray();
442
        return this.options.changes.pipe(
76✔
443
            map(data => {
75✔
444
                return this.options.toArray();
1✔
445
            }),
446
            filter(data => {
74✔
447
                const res = previousOptions.length !== data.length || previousOptions.some((op, index) => op !== data[index]);
448
                previousOptions = data;
449
                return res;
1✔
450
            })
451
        );
452
    }
453

10!
UNCOV
454
    private buildScrollStrategy() {
×
455
        if (this.scrollStrategyFactory && isFunction(this.scrollStrategyFactory)) {
456
            this.scrollStrategy = this.scrollStrategyFactory();
457
        } else {
10✔
458
            this.scrollStrategy = this.overlay.scrollStrategies.reposition();
459
        }
460
    }
461

462
    private isSearching = false;
179✔
463

2✔
464
    groupBy = (item: ThySelectOptionModel) => item.groupLabel;
465

179✔
466
    get placement(): ThyPlacement {
467
        return this.thyPlacement() || this.config.placement;
468
    }
469

470
    constructor() {
179✔
UNCOV
471
        super();
×
UNCOV
472
        const selectConfig = this.selectConfig;
×
473

474
        this.config = { ...DEFAULT_SELECT_CONFIG, ...selectConfig };
179✔
475
        this.buildScrollStrategy();
90✔
476

19✔
477
        effect(() => {
18✔
478
            this.mode = this.thyMode();
479
            untracked(() => {
480
                this.instanceSelectionModel();
71✔
481
                this.getPositions();
5✔
482
                this.setDropDownClass();
483
            });
484
        });
485
    }
486

4✔
487
    writeValue(value: any): void {
4✔
488
        this.modalValue = value;
4✔
489
        this.setSelectionByModelValue(this.modalValue);
4✔
490
    }
491

4!
492
    ngOnInit() {
1✔
493
        this.getPositions();
1✔
494
        this.dropDownMinWidth = this.getDropdownMinWidth();
495
        if (!this.selectionModel) {
3!
496
            this.instanceSelectionModel();
3✔
497
        }
2✔
498
        this.setDropDownClass();
2✔
499

500
        if (isPlatformBrowser(this.platformId)) {
501
            this.thyClickDispatcher
1✔
502
                .clicked(0)
503
                .pipe(takeUntil(this.destroy$))
504
                .subscribe(event => {
505
                    if (!this.elementRef.nativeElement.contains(event.target) && this.panelOpen) {
506
                        this.ngZone.run(() => {
13✔
507
                            this.close();
13✔
508
                            this.changeDetectorRef.markForCheck();
13✔
509
                        });
13✔
510
                    }
2✔
511
                });
2✔
512
        }
513
    }
11✔
514

515
    buildOptionGroups(options: ThySelectOptionModel[]) {
1✔
516
        const optionGroups: ThyOptionGroupModel[] = [];
1✔
517
        const groups = [...new Set(options.filter(item => this.groupBy(item)).map(sub => this.groupBy(sub)))];
518
        const groupMap = new Map();
10✔
519
        groups.forEach(group => {
3✔
520
            const children = options.filter(item => this.groupBy(item) === group);
3!
UNCOV
521
            const groupOption = {
×
UNCOV
522
                groupLabel: group,
×
523
                children: children
524
            };
525
            groupMap.set(group, groupOption);
3✔
526
        });
527
        options.forEach(option => {
7✔
528
            if (this.groupBy(option)) {
1✔
529
                const currentIndex = optionGroups.findIndex(item => item.groupLabel === this.groupBy(option));
1✔
530
                if (currentIndex === -1) {
1✔
531
                    const item = groupMap.get(this.groupBy(option));
8✔
532
                    optionGroups.push(item);
7!
533
                }
534
            } else {
535
                optionGroups.push(option);
536
            }
537
        });
6!
UNCOV
538
        return optionGroups;
×
539
    }
540

6✔
541
    buildReactiveOptions() {
6✔
542
        if (this.innerOptions.filter(item => this.groupBy(item)).length > 0) {
6!
543
            this.optionGroups = this.buildOptionGroups(this.innerOptions);
544
        } else {
545
            this.optionGroups = this.innerOptions;
546
        }
UNCOV
547
    }
×
548

549
    getDropdownMinWidth(): number | null {
550
        const mode = this.thyDropdownWidthMode() || this.config.dropdownWidthMode;
551
        let dropdownMinWidth: number | null = null;
552

367✔
553
        if ((mode as { minWidth: number })?.minWidth) {
554
            dropdownMinWidth = (mode as { minWidth: number }).minWidth;
555
        } else if (mode === 'min-width') {
350✔
556
            dropdownMinWidth = THY_SELECT_PANEL_MIN_WIDTH;
179✔
557
        } else {
558
            dropdownMinWidth = null;
350✔
559
        }
350✔
560

179✔
561
        return dropdownMinWidth;
179✔
562
    }
563

350✔
564
    ngAfterViewInit(): void {
101✔
565
        if (this.isReactiveDriven) {
101✔
566
            this.setup();
567
        }
568
    }
569

179✔
570
    ngAfterContentInit() {
179✔
571
        if (!this.isReactiveDriven) {
104✔
572
            this.setup();
104✔
573
        }
14✔
574
    }
14✔
575

576
    setup() {
577
        this.optionsChanges$.pipe(startWith(null), takeUntil(this.destroy$)).subscribe(data => {
578
            this.resetOptions();
579
            this.initializeSelection();
179✔
580
            this.initKeyManager();
179✔
581
            if (this.isSearching) {
582
                this.highlightCorrectOption(false);
583
                this.isSearching = false;
584
            }
367✔
585
            this.changeDetectorRef.markForCheck();
367✔
586
            this.ngZone.onStable
24✔
587
                .asObservable()
588
                .pipe(take(1))
589
                .subscribe(() => {
343✔
590
                    if (this.cdkConnectedOverlay() && this.cdkConnectedOverlay().overlayRef) {
591
                        this.cdkConnectedOverlay().overlayRef.updatePosition();
367✔
592
                    }
593
                });
594
        });
595

596
        if (this.thyAutoExpand()) {
597
            timer(0).subscribe(() => {
426✔
598
                this.changeDetectorRef.markForCheck();
236✔
599
                this.open();
1✔
600
                this.focus();
1✔
601
            });
602
        }
236✔
603
    }
604

190✔
605
    public get isHiddenOptions(): boolean {
28!
606
        return this.options.toArray().every(option => option.hidden);
28✔
607
    }
28✔
608

28✔
609
    public onAttached(): void {
185✔
610
        this.cdkConnectedOverlay()
2✔
611
            .positionChange.pipe(take(1))
25✔
612
            .subscribe(() => {
22✔
613
                if (this.panel()) {
614
                    if (this.keyManager.activeItem) {
615
                        ScrollToService.scrollToElement(this.keyManager.activeItem.element.nativeElement, this.panel().nativeElement);
616
                        this.changeDetectorRef.detectChanges();
617
                    } else {
618
                        if (!this.empty) {
162✔
619
                            ScrollToService.scrollToElement(
196✔
620
                                this.selectionModel.selected[0].element.nativeElement,
621
                                this.panel().nativeElement
162✔
622
                            );
135✔
623
                            this.changeDetectorRef.detectChanges();
624
                        }
625
                    }
190✔
626
                }
627
            });
628
    }
105✔
629

105!
UNCOV
630
    public dropDownMouseMove(event: MouseEvent) {
×
UNCOV
631
        if (this.keyManager.activeItem) {
×
632
            this.keyManager.setActiveItem(-1);
633
        }
634
    }
105✔
635

44✔
636
    public onOptionsScrolled(elementRef: ElementRef) {
637
        const scroll = elementRef.nativeElement.scrollTop,
105✔
638
            height = elementRef.nativeElement.clientHeight,
33✔
639
            scrollHeight = elementRef.nativeElement.scrollHeight;
640

105✔
641
        if (scroll + height + 10 >= scrollHeight) {
56✔
642
            this.ngZone.run(() => {
56✔
643
                this.thyOnScrollToBottom.emit();
12✔
644
            });
645
        }
646
    }
647

105✔
648
    public onSearchFilter(searchText: string) {
44✔
649
        searchText = searchText.trim();
650
        if (this.thyServerSearch()) {
105✔
651
            this.isSearching = true;
49✔
652
            this.thyOnSearch.emit(searchText);
653
        } else {
105✔
654
            const options = this.options.toArray();
655
            options.forEach(option => {
656
                if (option.matchSearchText(searchText)) {
56!
657
                    option.showOption();
56✔
658
                } else {
56✔
659
                    option.hideOption();
2✔
660
                }
1✔
661
            });
662
            this.highlightCorrectOption(false);
663
            this.updateCdkConnectedOverlayPositions();
664
        }
665
    }
666

81✔
667
    onBlur(event?: FocusEvent) {
668
        // Tab 聚焦后自动聚焦到 input 输入框,此分支下直接返回,无需触发 onTouchedFn
669
        if (elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-select-dropdown', 'thy-select'])) {
81✔
670
            return;
81✔
671
        }
81✔
672
        this.onTouchedFn();
81✔
673
    }
81✔
674

675
    onFocus(event?: FocusEvent) {
81✔
676
        // thyShowSearch 与 panelOpen 均为 true 时,点击 thySelectControl 需要触发自动聚焦到 input 的逻辑
677
        // manualFocusing 如果是手动聚焦,不触发自动聚焦到 input 的逻辑
UNCOV
678
        if (
×
679
            (this.thyShowSearch() && this.panelOpen) ||
680
            (!this.manualFocusing &&
UNCOV
681
                !elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-select-dropdown', 'thy-custom-select']))
×
UNCOV
682
        ) {
×
UNCOV
683
            const inputElement: HTMLInputElement = this.elementRef.nativeElement.querySelector('input');
×
UNCOV
684
            inputElement.focus();
×
685
        }
686
        this.manualFocusing = false;
687
    }
688

689
    public focus(options?: FocusOptions): void {
690
        this.manualFocusing = true;
283✔
691
        this.elementRef.nativeElement.focus(options);
81✔
692
        this.manualFocusing = false;
81✔
693
    }
694

695
    public remove($event: { item: ThyOption; $eventOrigin: Event }) {
696
        $event.$eventOrigin.stopPropagation();
172✔
697
        if (this.disabled) {
172✔
698
            return;
172✔
699
        }
700
        if (!this.options.find(option => option === $event.item)) {
1✔
701
            $event.item.deselect();
1✔
702
            // fix option unselect can not emit changes;
703
            this.onSelect($event.item, true);
704
        } else {
705
            $event.item.deselect();
706
        }
707
    }
708

709
    public clearSelectValue(event?: Event) {
710
        if (event) {
711
            event.stopPropagation();
712
        }
713
        if (this.disabled) {
714
            return;
715
        }
716
        this.selectionModel.clear();
717
        this.changeDetectorRef.markForCheck();
718
        this.emitModelValueChange();
719
    }
720

721
    public updateCdkConnectedOverlayPositions(): void {
722
        setTimeout(() => {
723
            if (this.cdkConnectedOverlay() && this.cdkConnectedOverlay().overlayRef) {
724
                this.cdkConnectedOverlay().overlayRef.updatePosition();
725
            }
726
        });
727
    }
728

729
    public get selected(): ThyOption | ThyOption[] {
730
        return this.isMultiple ? this.selectionModel.selected : this.selectionModel.selected[0];
731
    }
732

733
    public get isMultiple(): boolean {
734
        return this.mode === 'multiple';
735
    }
736

737
    public get empty(): boolean {
738
        return !this.selectionModel || this.selectionModel.isEmpty();
739
    }
740

1✔
741
    public getItemCount(): number {
742
        const group = this.isReactiveDriven ? this.viewGroups() : this.contentGroups();
743
        return this.options.length + group.length;
744
    }
745

746
    public toggle(event: MouseEvent): void {
747
        if (this.panelOpen) {
748
            if (!this.thyShowSearch()) {
749
                this.close();
750
            }
751
        } else {
752
            this.open();
156✔
753
        }
754
    }
755

756
    public open(): void {
757
        if (this.disabled || !this.options || this.panelOpen) {
758
            return;
759
        }
760
        this.triggerRectWidth = this.getOriginRectWidth();
761
        this.subscribeTriggerResize();
762
        this.panelOpen = true;
763
        this.highlightCorrectOption();
764
        this.thyOnExpandStatusChange.emit(this.panelOpen);
765
        this.changeDetectorRef.markForCheck();
766
    }
767

768
    public close(): void {
769
        if (this.panelOpen) {
770
            this.panelOpen = false;
771
            this.unsubscribeTriggerResize();
772
            this.thyOnExpandStatusChange.emit(this.panelOpen);
773
            this.changeDetectorRef.markForCheck();
774
            this.onTouchedFn();
775
        }
776
    }
777

778
    private emitModelValueChange() {
779
        const selectedValues = this.selectionModel.selected;
780
        const changeValue = selectedValues.map((option: ThyOption) => {
781
            return option.thyValue;
782
        });
783
        if (this.isMultiple) {
784
            this.modalValue = changeValue;
785
        } else {
786
            if (changeValue.length === 0) {
787
                this.modalValue = null;
788
            } else {
789
                this.modalValue = changeValue[0];
790
            }
791
        }
792
        this.onChangeFn(this.modalValue);
793
        this.updateCdkConnectedOverlayPositions();
794
    }
795

796
    private highlightCorrectOption(fromOpenPanel: boolean = true): void {
797
        if (this.keyManager && this.panelOpen) {
798
            if (fromOpenPanel) {
799
                if (this.keyManager.activeItem) {
800
                    return;
801
                }
802
                if (this.empty) {
803
                    if (!this.thyAutoActiveFirstItem()) {
804
                        return;
805
                    }
806
                    this.keyManager.setFirstItemActive();
807
                } else {
808
                    this.keyManager.setActiveItem(this.selectionModel.selected[0]);
809
                }
810
            } else {
811
                if (!this.thyAutoActiveFirstItem()) {
812
                    return;
813
                }
814
                // always set first option active
815
                this.keyManager.setFirstItemActive();
816
            }
817
        }
818
    }
819

820
    private initKeyManager() {
821
        if (this.keyManager && this.keyManager.activeItem) {
822
            this.keyManager.activeItem.setInactiveStyles();
823
        }
824
        this.keyManager = new ActiveDescendantKeyManager<ThyOption>(this.options)
825
            .withTypeAhead()
826
            .withWrap()
827
            .withVerticalOrientation()
828
            .withAllowedModifierKeys(['shiftKey']);
829

830
        this.keyManager.tabOut.pipe(takeUntil(this.destroy$)).subscribe(() => {
831
            this.focus();
832
            this.close();
833
        });
834
        this.keyManager.change.pipe(takeUntil(this.destroy$)).subscribe(() => {
835
            if (this.panelOpen && this.panel()) {
836
                if (this.keyManager.activeItem) {
837
                    ScrollToService.scrollToElement(this.keyManager.activeItem.element.nativeElement, this.panel().nativeElement);
838
                }
839
            } else if (!this.panelOpen && !this.isMultiple && this.keyManager.activeItem) {
840
                this.keyManager.activeItem.selectViaInteraction();
841
            }
842
        });
843
    }
844

845
    private handleClosedKeydown(event: KeyboardEvent): void {
846
        const keyCode = event.keyCode;
847
        const isArrowKey = keyCode === DOWN_ARROW || keyCode === UP_ARROW || keyCode === LEFT_ARROW || keyCode === RIGHT_ARROW;
848
        const isOpenKey = keyCode === ENTER || keyCode === SPACE;
849
        const manager = this.keyManager;
850

851
        // Open the select on ALT + arrow key to match the native <select>
852
        if ((isOpenKey && !hasModifierKey(event)) || ((this.isMultiple || event.altKey) && isArrowKey)) {
853
            event.preventDefault(); // prevents the page from scrolling down when pressing space
854
            this.open();
855
        } else if (!this.isMultiple) {
856
            if (keyCode === HOME || keyCode === END) {
857
                keyCode === HOME ? manager.setFirstItemActive() : manager.setLastItemActive();
858
                event.preventDefault();
859
            } else {
860
                manager.onKeydown(event);
861
            }
862
        }
863
    }
864

865
    private handleOpenKeydown(event: KeyboardEvent): void {
866
        const keyCode = event.keyCode;
867
        const isArrowKey = keyCode === DOWN_ARROW || keyCode === UP_ARROW;
868
        const manager = this.keyManager;
869

870
        if (keyCode === HOME || keyCode === END) {
871
            event.preventDefault();
872
            keyCode === HOME ? manager.setFirstItemActive() : manager.setLastItemActive();
873
        } else if (isArrowKey && event.altKey) {
874
            // Close the select on ALT + arrow key to match the native <select>
875
            event.preventDefault();
876
            this.close();
877
        } else if ((keyCode === ENTER || keyCode === SPACE) && (manager.activeItem || !this.empty) && !hasModifierKey(event)) {
878
            event.preventDefault();
879
            if (!manager.activeItem) {
880
                if (manager.activeItemIndex === -1 && !this.empty) {
881
                    manager.setActiveItem(this.selectionModel.selected[0]);
882
                }
883
            }
884
            manager.activeItem.selectViaInteraction();
885
        } else if (this.isMultiple && keyCode === A && event.ctrlKey) {
886
            event.preventDefault();
887
            const hasDeselectedOptions = this.options.some(opt => !opt.disabled && !opt.selected);
888

889
            this.options.forEach(option => {
890
                if (!option.disabled) {
891
                    hasDeselectedOptions ? option.select() : option.deselect();
892
                }
893
            });
894
        } else {
895
            if (manager.activeItemIndex === -1 && !this.empty) {
896
                manager.setActiveItem(this.selectionModel.selected[0]);
897
            }
898
            const previouslyFocusedIndex = manager.activeItemIndex;
899

900
            manager.onKeydown(event);
901

902
            if (
903
                this.isMultiple &&
904
                isArrowKey &&
905
                event.shiftKey &&
906
                manager.activeItem &&
907
                manager.activeItemIndex !== previouslyFocusedIndex
908
            ) {
909
                manager.activeItem.selectViaInteraction();
910
            }
911
        }
912
    }
913

914
    private getPositions() {
915
        this.dropDownPositions = getFlexiblePositions(this.thyPlacement() || this.config.placement, this.defaultOffset);
916
    }
917

918
    private instanceSelectionModel() {
919
        if (this.selectionModel) {
920
            this.selectionModel.clear();
921
        }
922
        this.selectionModel = new SelectionModel<ThyOption>(this.isMultiple);
923
        if (this.selectionModelSubscription) {
924
            this.selectionModelSubscription.unsubscribe();
925
            this.selectionModelSubscription = null;
926
        }
927
        this.selectionModelSubscription = this.selectionModel.changed.pipe(takeUntil(this.destroy$)).subscribe(event => {
928
            event.added.forEach(option => option.select());
929
            event.removed.forEach(option => option.deselect());
930
        });
931
    }
932

933
    private resetOptions() {
934
        const changedOrDestroyed$ = merge(this.optionsChanges$, this.destroy$);
935

936
        this.optionSelectionChanges.pipe(takeUntil(changedOrDestroyed$)).subscribe((event: ThyOptionSelectionChangeEvent) => {
937
            this.onSelect(event.option, event.isUserInput);
938
            if (event.isUserInput && !this.isMultiple && this.panelOpen) {
939
                this.close();
940
                this.focus();
941
            }
942
        });
943
    }
944

945
    private initializeSelection() {
946
        Promise.resolve().then(() => {
947
            this.setSelectionByModelValue(this.modalValue);
948
        });
949
    }
950

951
    private setDropDownClass() {
952
        let modeClass = '';
953
        if (this.isMultiple) {
954
            modeClass = `thy-select-dropdown-${this.mode}`;
955
        } else {
956
            modeClass = `thy-select-dropdown-single`;
957
        }
958
        this.dropDownClass = {
959
            [`thy-select-dropdown`]: true,
960
            [modeClass]: true
961
        };
962
    }
963

964
    private setSelectionByModelValue(modalValue: any) {
965
        if (helpers.isUndefinedOrNull(modalValue)) {
966
            if (this.selectionModel.selected.length > 0) {
967
                this.selectionModel.clear();
968
                this.changeDetectorRef.markForCheck();
969
            }
970
            return;
971
        }
972
        if (this.isMultiple) {
973
            if (isArray(modalValue)) {
974
                const selected = [...this.selectionModel.selected];
975
                this.selectionModel.clear();
976
                (modalValue as Array<any>).forEach(itemValue => {
977
                    const option =
978
                        this.options.find(_option => _option.thyValue === itemValue) ||
979
                        selected.find(_option => _option.thyValue === itemValue);
980
                    if (option) {
981
                        this.selectionModel.select(option);
982
                    }
983
                });
984
            }
985
        } else {
986
            const selectedOption = this.options?.find(option => {
987
                return option.thyValue === modalValue;
988
            });
989
            if (selectedOption) {
990
                this.selectionModel.select(selectedOption);
991
            }
992
        }
993
        this.changeDetectorRef.markForCheck();
994
    }
995

996
    private onSelect(option: ThyOption, isUserInput: boolean) {
997
        const wasSelected = this.selectionModel.isSelected(option);
998

999
        if (option.thyValue == null && !this.isMultiple) {
1000
            option.deselect();
1001
            this.selectionModel.clear();
1002
        } else {
1003
            if (wasSelected !== option.selected) {
1004
                option.selected ? this.selectionModel.select(option) : this.selectionModel.deselect(option);
1005
            }
1006

1007
            if (isUserInput) {
1008
                this.keyManager.setActiveItem(option);
1009
            }
1010

1011
            if (this.isMultiple) {
1012
                this.sortValues();
1013
                if (isUserInput) {
1014
                    this.focus();
1015
                }
1016
            }
1017
        }
1018

1019
        if (wasSelected !== this.selectionModel.isSelected(option)) {
1020
            this.emitModelValueChange();
1021
        }
1022
        if (!this.isMultiple) {
1023
            this.onTouchedFn();
1024
        }
1025
        this.changeDetectorRef.markForCheck();
1026
    }
1027

1028
    private sortValues() {
1029
        if (this.isMultiple) {
1030
            const options = this.options.toArray();
1031

1032
            if (this.thySortComparator()) {
1033
                this.selectionModel.sort((a, b) => {
1034
                    return this.thySortComparator()(a, b, options);
1035
                });
1036
            }
1037
        }
1038
    }
1039

1040
    private getOriginRectWidth() {
1041
        return this.thyOrigin() ? coerceElement(this.thyOrigin()).offsetWidth : this.trigger.nativeElement.offsetWidth;
1042
    }
1043

1044
    private subscribeTriggerResize(): void {
1045
        this.unsubscribeTriggerResize();
1046
        this.ngZone.runOutsideAngular(() => {
1047
            this.resizeSubscription = new Observable<number>(observer => {
1048
                const resize = new ResizeObserver((entries: ResizeObserverEntry[]) => {
1049
                    observer.next(null);
1050
                });
1051
                resize.observe(this.trigger.nativeElement);
1052
            })
1053
                .pipe(
1054
                    startWith(),
1055
                    map(() => {
1056
                        return this.getOriginRectWidth();
1057
                    }),
1058
                    distinctUntilChanged()
1059
                )
1060
                .subscribe((width: number) => {
1061
                    this.ngZone.run(() => {
1062
                        this.triggerRectWidth = width;
1063
                        this.updateCdkConnectedOverlayPositions();
1064
                        this.changeDetectorRef.markForCheck();
1065
                    });
1066
                });
1067
        });
1068
    }
1069

1070
    private unsubscribeTriggerResize(): void {
1071
        if (this.resizeSubscription) {
1072
            this.resizeSubscription.unsubscribe();
1073
            this.resizeSubscription = null;
1074
        }
1075
    }
1076

1077
    ngOnDestroy() {
1078
        this.unsubscribeTriggerResize();
1079
        this.destroy$.next();
1080
        this.destroy$.complete();
1081
    }
1082
}
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