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

atinc / ngx-tethys / d4d3bf94-0b08-4dba-b53c-fd90c5e0b80a

27 Mar 2025 01:46AM UTC coverage: 90.236% (+0.06%) from 90.179%
d4d3bf94-0b08-4dba-b53c-fd90c5e0b80a

push

circleci

minlovehua
feat: save draft

5598 of 6865 branches covered (81.54%)

Branch coverage included in aggregate %.

8 of 8 new or added lines in 7 files covered. (100.0%)

157 existing lines in 46 files now uncovered.

13357 of 14141 relevant lines covered (94.46%)

992.52 hits per line

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

92.2
/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,
1✔
20
    ThyOptionSelectionChangeEvent,
1✔
21
    ThyScrollDirective,
1✔
22
    ThySelectControl,
1✔
23
    ThySelectOptionGroup,
1✔
24
    ThyStopPropagationDirective
1✔
25
} from 'ngx-tethys/shared';
26
import {
27
    A,
28
    coerceBooleanProperty,
29
    DOWN_ARROW,
30
    elementMatchClosest,
1✔
31
    END,
32
    ENTER,
78✔
33
    FunctionProp,
78✔
34
    hasModifierKey,
78✔
35
    helpers,
78✔
36
    HOME,
37
    isArray,
38
    isFunction,
1✔
39
    LEFT_ARROW,
40
    RIGHT_ARROW,
41
    SPACE,
447✔
42
    UP_ARROW
43
} from 'ngx-tethys/util';
44
import { defer, merge, Observable, Subject, Subscription, timer } from 'rxjs';
114✔
45
import { distinctUntilChanged, filter, map, startWith, switchMap, take, takeUntil } from 'rxjs/operators';
46

47
import { ActiveDescendantKeyManager } from '@angular/cdk/a11y';
1✔
48
import { coerceElement } from '@angular/cdk/coercion';
49
import { SelectionModel } from '@angular/cdk/collections';
50
import { CdkConnectedOverlay, CdkOverlayOrigin, ConnectionPositionPair, Overlay, ScrollStrategy } from '@angular/cdk/overlay';
2✔
51
import { isPlatformBrowser, NgClass, NgTemplateOutlet } from '@angular/common';
52
import {
53
    AfterContentInit,
114✔
54
    AfterViewInit,
55
    ChangeDetectionStrategy,
56
    ChangeDetectorRef,
886✔
57
    Component,
58
    ContentChild,
59
    ContentChildren,
4!
60
    ElementRef,
×
61
    EventEmitter,
62
    forwardRef,
4✔
63
    HostBinding,
4✔
64
    HostListener,
4✔
65
    Input,
66
    NgZone,
67
    numberAttribute,
17!
68
    OnDestroy,
17✔
69
    OnInit,
5✔
70
    Output,
71
    PLATFORM_ID,
17✔
72
    QueryList,
73
    TemplateRef,
74
    ViewChild,
75
    ViewChildren,
350✔
76
    inject,
350✔
77
    Signal
350✔
78
} from '@angular/core';
16✔
79
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
80

16✔
81
import {
16✔
82
    DEFAULT_SELECT_CONFIG,
16✔
83
    THY_SELECT_CONFIG,
84
    THY_SELECT_SCROLL_STRATEGY,
85
    ThyDropdownWidthMode,
86
    ThySelectConfig
172!
87
} from '../select.config';
172✔
88
import { injectLocale, ThySelectLocale } from 'ngx-tethys/i18n';
89

90
export type SelectMode = 'multiple' | '';
×
91

92
export type ThySelectTriggerType = 'click' | 'hover';
93

94
export const SELECT_PANEL_MAX_HEIGHT = 300;
1,520✔
95

96
export const SELECT_OPTION_MAX_HEIGHT = 40;
97

172✔
98
export const SELECT_OPTION_GROUP_MAX_HEIGHT = 30;
172✔
99

172✔
100
export const SELECT_PANEL_PADDING_TOP = 10;
172✔
101

172✔
102
export const THY_SELECT_PANEL_MIN_WIDTH = 200;
172✔
103

172✔
104
export interface OptionValue {
172✔
105
    thyLabelText?: string;
172✔
106
    thyValue?: string;
172✔
107
    thyDisabled?: boolean;
172✔
108
    thyShowOptionCustom?: boolean;
172✔
109
    thySearchKey?: string;
172✔
110
}
172✔
111

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

172✔
120
interface ThyOptionGroupModel extends ThySelectOptionModel {
172✔
121
    children?: ThySelectOptionModel[];
172✔
122
}
179!
123

876✔
124
const noop = () => {};
125

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

1✔
183
    disabled = false;
184

185
    size: SelectControlSize;
186

1✔
187
    mode: SelectMode = '';
188

1✔
189
    emptyStateText = this.locale().empty;
3✔
190

2✔
191
    emptySearchMessageText = this.locale().empty;
2✔
192

1✔
193
    scrollTop = 0;
1✔
194

195
    modalValue: any = null;
196

197
    defaultOffset = 4;
1✔
198

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

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

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

209
    public dropDownPositions: ConnectionPositionPair[];
210

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

188✔
213
    public triggerRectWidth: number;
188✔
214

1✔
215
    public scrollStrategy: ScrollStrategy;
216

187✔
217
    private resizeSubscription: Subscription;
4✔
218

219
    private selectionModelSubscription: Subscription;
220

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

171✔
226
    private config: ThySelectConfig;
3✔
227

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

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

179✔
240
    @ViewChild(CdkConnectedOverlay, { static: true }) cdkConnectedOverlay: CdkConnectedOverlay;
1✔
241

1✔
242
    @HostBinding('class.thy-select-custom') isSelectCustom = true;
243

179✔
244
    @HostBinding('class.thy-select') isSelect = true;
179✔
245

246
    keyManager: ActiveDescendantKeyManager<ThyOption>;
247

248
    @HostBinding('class.menu-is-opened')
179✔
249
    panelOpen = false;
42✔
250

251
    /**
252
     * 搜索时回调
253
     */
171✔
254
    @Output() thyOnSearch: EventEmitter<string> = new EventEmitter<string>();
1✔
255

1✔
256
    /**
1✔
257
     * 下拉菜单滚动到底部事件,可以用这个事件实现滚动加载
1✔
258
     */
259
    @Output() thyOnScrollToBottom: EventEmitter<void> = new EventEmitter<void>();
260

261
    /**
262
     * 下拉菜单展开和折叠状态事件
249✔
263
     */
264
    @Output() thyOnExpandStatusChange: EventEmitter<boolean> = new EventEmitter<boolean>();
265

80✔
266
    /**
74✔
267
     * 下拉列表是否显示搜索框
64✔
268
     * @default false
62✔
269
     */
62✔
270
    @Input({ transform: coerceBooleanProperty }) thyShowSearch: boolean;
271

272
    /**
2!
273
     * 选择框默认文字
×
UNCOV
274
     */
×
275
    @Input() thyPlaceHolder = this.locale().placeholder;
276

277
    /**
278
     * 是否使用服务端搜索,当为 true 时,将不再在前端进行过滤
279
     * @default false
280
     */
281
    @Input({ transform: coerceBooleanProperty }) thyServerSearch: boolean;
1!
282

1✔
283
    /**
284
     * 异步加载 loading 状态,false 表示加载中,true 表示加载完成
285
     */
286
    @Input({ transform: coerceBooleanProperty }) thyLoadState = true;
1✔
287

1!
288
    /**
1!
289
     * 是否自动设置选项第一条为高亮状态
1✔
290
     */
1✔
291
    @Input({ transform: coerceBooleanProperty }) thyAutoActiveFirstItem = true;
292

293
    /**
294
     * 下拉选择模式
295
     * @type 'multiple' | ''
296
     */
11✔
297
    @Input()
11✔
298
    set thyMode(value: SelectMode) {
2✔
299
        this.mode = value;
2✔
300
        this.instanceSelectionModel();
301
        this.getPositions();
302
        this.setDropDownClass();
9✔
303
    }
9✔
304

52✔
305
    get thyMode(): SelectMode {
12✔
306
        return this.mode;
307
    }
308

40✔
309
    /**
310
     * 操作图标类型
311
     * @type primary | success | danger | warning
9✔
312
     * @default primary
9✔
313
     */
314
    @Input()
315
    get thySize(): SelectControlSize {
316
        return this.size;
317
    }
2✔
318
    set thySize(value: SelectControlSize) {
1✔
319
        this.size = value;
320
    }
1✔
321

322
    /**
323
     * 数据为空时显示的提示文字
324
     */
325
    @Input()
24!
326
    set thyEmptyStateText(value: string) {
327
        this.emptyStateText = value;
328
    }
3✔
329

3✔
330
    /**
331
     * 搜索结果为空时显示的提示文字
24✔
332
     */
333
    @Input()
334
    set thyEmptySearchMessageText(value: string) {
28✔
335
        this.emptySearchMessageText = value;
28✔
336
    }
28✔
337

338
    /**
339
     * 滚动加载是否可用,只能当这个参数可以,下面的thyOnScrollToBottom事件才会触发
4✔
340
     */
4✔
341
    @Input({ transform: coerceBooleanProperty })
1✔
342
    thyEnableScrollLoad = false;
343

18✔
344
    /**
1✔
345
     * 单选( thyMode="" 或者不设置)时,选择框支持清除
346
     */
1✔
347
    @Input({ transform: coerceBooleanProperty }) thyAllowClear = false;
348

349
    /**
2✔
350
     * 是否禁用
351
     * @default false
352
     */
353
    @Input({ transform: coerceBooleanProperty })
4✔
354
    set thyDisabled(value: boolean) {
3✔
355
        this.disabled = value;
356
    }
4✔
357
    get thyDisabled(): boolean {
1✔
358
        return this.disabled;
359
    }
3✔
360

3✔
361
    /**
3✔
362
     * 排序比较函数
363
     */
364
    @Input() thySortComparator: (a: ThyOption, b: ThyOption, options: ThyOption[]) => number;
56✔
365

56✔
366
    /**
43✔
367
     * Footer 模板,默认值为空不显示 Footer
368
     * @type TemplateRef
369
     */
370
    @Input()
371
    thyFooterTemplate: TemplateRef<any>;
449✔
372

373
    /**
374
     * 弹出位置
1,932✔
375
     * @type ThyPlacement
376
     */
377
    @Input()
80✔
378
    thyPlacement: ThyPlacement;
379

380
    /**
1!
381
     * 自定义 Overlay Origin
1✔
382
     */
383
    @Input()
384
    thyOrigin: ElementRef | HTMLElement;
90✔
385

10✔
386
    /**
9✔
387
     * 自定义 Footer 模板容器 class
388
     */
389
    @Input()
390
    thyFooterClass = 'thy-custom-select-footer';
80✔
391

392
    /**
393
     * @private
394
     */
83✔
395
    @ContentChild('selectedDisplay') selectedValueDisplayRef: TemplateRef<any>;
2✔
396

397
    /**
81✔
398
     * 初始化时,是否展开面板
81✔
399
     * @default false
81✔
400
     */
81✔
401
    @Input({ transform: coerceBooleanProperty }) thyAutoExpand: boolean;
81✔
402

81✔
403
    /**
404
     * 是否弹出透明遮罩,如果显示遮罩则会阻止滚动区域滚动
405
     */
57✔
406
    @Input({ transform: coerceBooleanProperty })
30✔
407
    thyHasBackdrop = false;
30✔
408

30✔
409
    /**
30✔
410
     * 设置多选时最大显示的标签数量,0 表示不限制
30✔
411
     */
412
    @Input({ transform: numberAttribute }) thyMaxTagCount = 0;
413

414
    /**
47✔
415
     * 是否隐藏选择框边框
47✔
416
     * @default false
66✔
417
     */
418
    @Input({ transform: coerceBooleanProperty }) thyBorderless = false;
47✔
419

25✔
420
    isReactiveDriven = false;
421

422
    innerOptions: ThySelectOptionModel[];
22✔
423

2✔
424
    optionGroups: ThyOptionGroupModel[] = [];
425

426
    /**
20✔
427
     * option 列表
428
     * @type ThySelectOptionModel[]
429
     */
47✔
430
    @Input()
47✔
431
    set thyOptions(value: ThySelectOptionModel[]) {
432
        if (value === null) {
81✔
433
            value = [];
91!
434
        }
91✔
435
        this.innerOptions = value;
81✔
436
        this.isReactiveDriven = true;
5✔
437
        this.buildReactiveOptions();
438
    }
76✔
439

75✔
440
    options: QueryList<ThyOption>;
1✔
441

442
    /**
74✔
443
     * 目前只支持多选选中项的展示,默认为空,渲染文字模板,传入tag,渲染展示模板,
444
     * @default ''|tag
445
     */
1✔
446
    @Input() thyPreset: string = '';
447

448
    @ViewChild('trigger', { read: ElementRef, static: true }) trigger: ElementRef<HTMLElement>;
449

10!
UNCOV
450
    @ViewChild('panel', { read: ElementRef }) panel: ElementRef<HTMLElement>;
×
451

452
    /**
453
     * @private
10✔
454
     */
455
    @ContentChildren(ThyOption, { descendants: true }) contentOptions: QueryList<ThyOption>;
456

457
    @ViewChildren(ThyOption) viewOptions: QueryList<ThyOption>;
458

179✔
459
    /**
2✔
460
     * @private
461
     */
179✔
462
    @ContentChildren(ThySelectOptionGroup) contentGroups: QueryList<ThySelectOptionGroup>;
463

464
    @ViewChildren(ThySelectOptionGroup) viewGroups: QueryList<ThySelectOptionGroup>;
465

466
    @HostListener('keydown', ['$event'])
179✔
467
    handleKeydown(event: KeyboardEvent): void {
×
UNCOV
468
        if (!this.disabled) {
×
469
            if (event.keyCode === ENTER) {
470
                event.stopPropagation();
179✔
471
            }
90✔
472
            this.panelOpen ? this.handleOpenKeydown(event) : this.handleClosedKeydown(event);
19✔
473
        }
18✔
474
    }
475

476
    get optionsChanges$() {
71✔
477
        this.options = this.isReactiveDriven ? this.viewOptions : this.contentOptions;
5✔
478
        let previousOptions: ThyOption[] = this.options.toArray();
479
        return this.options.changes.pipe(
480
            map(data => {
481
                return this.options.toArray();
482
            }),
4✔
483
            filter(data => {
4✔
484
                const res = previousOptions.length !== data.length || previousOptions.some((op, index) => op !== data[index]);
4✔
485
                previousOptions = data;
4✔
486
                return res;
487
            })
4!
488
        );
1✔
489
    }
1✔
490

491
    private buildScrollStrategy() {
3!
492
        if (this.scrollStrategyFactory && isFunction(this.scrollStrategyFactory)) {
3✔
493
            this.scrollStrategy = this.scrollStrategyFactory();
2✔
494
        } else {
2✔
495
            this.scrollStrategy = this.overlay.scrollStrategies.reposition();
496
        }
497
    }
1✔
498

499
    private isSearching = false;
500

501
    groupBy = (item: ThySelectOptionModel) => item.groupLabel;
502

13✔
503
    get placement(): ThyPlacement {
13✔
504
        return this.thyPlacement || this.config.placement;
13✔
505
    }
13✔
506

2✔
507
    constructor() {
2✔
508
        super();
509
        const selectConfig = this.selectConfig;
11✔
510

511
        this.config = { ...DEFAULT_SELECT_CONFIG, ...selectConfig };
1✔
512
        this.buildScrollStrategy();
1✔
513
    }
514

10✔
515
    writeValue(value: any): void {
3✔
516
        this.modalValue = value;
3!
517
        this.setSelectionByModelValue(this.modalValue);
×
UNCOV
518
    }
×
519

520
    ngOnInit() {
521
        this.getPositions();
3✔
522
        this.dropDownMinWidth = this.getDropdownMinWidth();
523
        if (!this.selectionModel) {
7✔
524
            this.instanceSelectionModel();
1✔
525
        }
1✔
526
        this.setDropDownClass();
1✔
527

8✔
528
        if (isPlatformBrowser(this.platformId)) {
7!
529
            this.thyClickDispatcher
530
                .clicked(0)
531
                .pipe(takeUntil(this.destroy$))
532
                .subscribe(event => {
533
                    if (!this.elementRef.nativeElement.contains(event.target) && this.panelOpen) {
6!
UNCOV
534
                        this.ngZone.run(() => {
×
535
                            this.close();
536
                            this.changeDetectorRef.markForCheck();
6✔
537
                        });
6✔
538
                    }
6!
539
                });
540
        }
541
    }
542

UNCOV
543
    buildOptionGroups(options: ThySelectOptionModel[]) {
×
544
        const optionGroups: ThyOptionGroupModel[] = [];
545
        const groups = [...new Set(options.filter(item => this.groupBy(item)).map(sub => this.groupBy(sub)))];
546
        const groupMap = new Map();
547
        groups.forEach(group => {
548
            const children = options.filter(item => this.groupBy(item) === group);
266✔
549
            const groupOption = {
550
                groupLabel: group,
551
                children: children
178✔
552
            };
7✔
553
            groupMap.set(group, groupOption);
554
        });
178✔
555
        options.forEach(option => {
178✔
556
            if (this.groupBy(option)) {
7✔
557
                const currentIndex = optionGroups.findIndex(item => item.groupLabel === this.groupBy(option));
7✔
558
                if (currentIndex === -1) {
559
                    const item = groupMap.get(this.groupBy(option));
178✔
560
                    optionGroups.push(item);
101✔
561
                }
101✔
562
            } else {
563
                optionGroups.push(option);
564
            }
565
        });
179✔
566
        return optionGroups;
179✔
567
    }
104✔
568

104✔
569
    buildReactiveOptions() {
14✔
570
        if (this.innerOptions.filter(item => this.groupBy(item)).length > 0) {
14✔
571
            this.optionGroups = this.buildOptionGroups(this.innerOptions);
572
        } else {
573
            this.optionGroups = this.innerOptions;
574
        }
575
    }
179✔
576

179✔
577
    getDropdownMinWidth(): number | null {
578
        const mode = this.thyDropdownWidthMode || this.config.dropdownWidthMode;
579
        let dropdownMinWidth: number | null = null;
580

266✔
581
        if ((mode as { minWidth: number })?.minWidth) {
266✔
582
            dropdownMinWidth = (mode as { minWidth: number }).minWidth;
43✔
583
        } else if (mode === 'min-width') {
584
            dropdownMinWidth = THY_SELECT_PANEL_MIN_WIDTH;
585
        } else {
223✔
586
            dropdownMinWidth = null;
587
        }
266✔
588

589
        return dropdownMinWidth;
590
    }
591

592
    ngAfterViewInit(): void {
593
        if (this.isReactiveDriven) {
426✔
594
            this.setup();
236✔
595
        }
1✔
596
    }
1✔
597

598
    ngAfterContentInit() {
236✔
599
        if (!this.isReactiveDriven) {
600
            this.setup();
190✔
601
        }
28!
602
    }
28✔
603

28✔
604
    setup() {
28✔
605
        this.optionsChanges$.pipe(startWith(null), takeUntil(this.destroy$)).subscribe(data => {
185✔
606
            this.resetOptions();
2✔
607
            this.initializeSelection();
25✔
608
            this.initKeyManager();
22✔
609
            if (this.isSearching) {
610
                this.highlightCorrectOption(false);
611
                this.isSearching = false;
612
            }
613
            this.changeDetectorRef.markForCheck();
614
            this.ngZone.onStable
162✔
615
                .asObservable()
184✔
616
                .pipe(take(1))
617
                .subscribe(() => {
162✔
618
                    if (this.cdkConnectedOverlay && this.cdkConnectedOverlay.overlayRef) {
131✔
619
                        this.cdkConnectedOverlay.overlayRef.updatePosition();
620
                    }
621
                });
190✔
622
        });
623

624
        if (this.thyAutoExpand) {
105✔
625
            timer(0).subscribe(() => {
105!
626
                this.changeDetectorRef.markForCheck();
×
UNCOV
627
                this.open();
×
628
                this.focus();
629
            });
630
        }
105✔
631
    }
44✔
632

633
    public get isHiddenOptions(): boolean {
105✔
634
        return this.options.toArray().every(option => option.hidden);
33✔
635
    }
636

105✔
637
    public onAttached(): void {
56✔
638
        this.cdkConnectedOverlay.positionChange.pipe(take(1)).subscribe(() => {
56✔
639
            if (this.panel) {
12✔
640
                if (this.keyManager.activeItem) {
641
                    ScrollToService.scrollToElement(this.keyManager.activeItem.element.nativeElement, this.panel.nativeElement);
642
                    this.changeDetectorRef.detectChanges();
643
                } else {
105✔
644
                    if (!this.empty) {
44✔
645
                        ScrollToService.scrollToElement(this.selectionModel.selected[0].element.nativeElement, this.panel.nativeElement);
646
                        this.changeDetectorRef.detectChanges();
105✔
647
                    }
49✔
648
                }
649
            }
105✔
650
        });
651
    }
652

56!
653
    public dropDownMouseMove(event: MouseEvent) {
56✔
654
        if (this.keyManager.activeItem) {
56✔
655
            this.keyManager.setActiveItem(-1);
2✔
656
        }
1✔
657
    }
658

659
    public onOptionsScrolled(elementRef: ElementRef) {
660
        const scroll = elementRef.nativeElement.scrollTop,
661
            height = elementRef.nativeElement.clientHeight,
662
            scrollHeight = elementRef.nativeElement.scrollHeight;
81✔
663

664
        if (scroll + height + 10 >= scrollHeight) {
665
            if (this.thyOnScrollToBottom.observers.length > 0) {
81✔
666
                this.ngZone.run(() => {
81✔
667
                    this.thyOnScrollToBottom.emit();
81✔
668
                });
81✔
669
            }
81✔
670
        }
671
    }
81✔
672

673
    public onSearchFilter(searchText: string) {
UNCOV
674
        searchText = searchText.trim();
×
675
        if (this.thyServerSearch) {
676
            this.isSearching = true;
677
            this.thyOnSearch.emit(searchText);
×
678
        } else {
×
679
            const options = this.options.toArray();
×
UNCOV
680
            options.forEach(option => {
×
681
                if (option.matchSearchText(searchText)) {
682
                    option.showOption();
683
                } else {
684
                    option.hideOption();
685
                }
686
            });
283✔
687
            this.highlightCorrectOption(false);
81✔
688
            this.updateCdkConnectedOverlayPositions();
81✔
689
        }
690
    }
691

692
    onBlur(event?: FocusEvent) {
172✔
693
        // Tab 聚焦后自动聚焦到 input 输入框,此分支下直接返回,无需触发 onTouchedFn
172✔
694
        if (elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-select-dropdown', 'thy-select'])) {
172✔
695
            return;
696
        }
1✔
697
        this.onTouchedFn();
1✔
698
    }
699

700
    onFocus(event?: FocusEvent) {
701
        // thyShowSearch 与 panelOpen 均为 true 时,点击 thySelectControl 需要触发自动聚焦到 input 的逻辑
702
        // manualFocusing 如果是手动聚焦,不触发自动聚焦到 input 的逻辑
703
        if (
704
            (this.thyShowSearch && this.panelOpen) ||
705
            (!this.manualFocusing &&
706
                !elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-select-dropdown', 'thy-custom-select']))
707
        ) {
708
            const inputElement: HTMLInputElement = this.elementRef.nativeElement.querySelector('input');
709
            inputElement.focus();
710
        }
711
        this.manualFocusing = false;
712
    }
713

714
    public focus(options?: FocusOptions): void {
715
        this.manualFocusing = true;
716
        this.elementRef.nativeElement.focus(options);
717
        this.manualFocusing = false;
718
    }
719

720
    public remove($event: { item: ThyOption; $eventOrigin: Event }) {
721
        $event.$eventOrigin.stopPropagation();
722
        if (this.disabled) {
723
            return;
724
        }
725
        if (!this.options.find(option => option === $event.item)) {
726
            $event.item.deselect();
727
            // fix option unselect can not emit changes;
728
            this.onSelect($event.item, true);
729
        } else {
730
            $event.item.deselect();
731
        }
732
    }
733

734
    public clearSelectValue(event?: Event) {
735
        if (event) {
736
            event.stopPropagation();
737
        }
738
        if (this.disabled) {
739
            return;
1✔
740
        }
741
        this.selectionModel.clear();
742
        this.changeDetectorRef.markForCheck();
743
        this.emitModelValueChange();
744
    }
745

746
    public updateCdkConnectedOverlayPositions(): void {
747
        setTimeout(() => {
748
            if (this.cdkConnectedOverlay && this.cdkConnectedOverlay.overlayRef) {
749
                this.cdkConnectedOverlay.overlayRef.updatePosition();
750
            }
751
        });
156✔
752
    }
753

754
    public get selected(): ThyOption | ThyOption[] {
755
        return this.isMultiple ? this.selectionModel.selected : this.selectionModel.selected[0];
756
    }
757

758
    public get isMultiple(): boolean {
759
        return this.mode === 'multiple';
760
    }
761

762
    public get empty(): boolean {
763
        return !this.selectionModel || this.selectionModel.isEmpty();
764
    }
765

766
    public getItemCount(): number {
767
        const group = this.isReactiveDriven ? this.viewGroups : this.contentGroups;
768
        return this.options.length + group.length;
769
    }
770

771
    public toggle(event: MouseEvent): void {
772
        if (this.panelOpen) {
773
            if (!this.thyShowSearch) {
774
                this.close();
775
            }
776
        } else {
777
            this.open();
778
        }
779
    }
780

781
    public open(): void {
782
        if (this.disabled || !this.options || this.panelOpen) {
783
            return;
784
        }
785
        this.triggerRectWidth = this.getOriginRectWidth();
786
        this.subscribeTriggerResize();
787
        this.panelOpen = true;
788
        this.highlightCorrectOption();
789
        this.thyOnExpandStatusChange.emit(this.panelOpen);
790
        this.changeDetectorRef.markForCheck();
791
    }
792

793
    public close(): void {
794
        if (this.panelOpen) {
795
            this.panelOpen = false;
796
            this.unsubscribeTriggerResize();
797
            this.thyOnExpandStatusChange.emit(this.panelOpen);
798
            this.changeDetectorRef.markForCheck();
799
            this.onTouchedFn();
800
        }
801
    }
802

803
    private emitModelValueChange() {
804
        const selectedValues = this.selectionModel.selected;
805
        const changeValue = selectedValues.map((option: ThyOption) => {
806
            return option.thyValue;
807
        });
808
        if (this.isMultiple) {
809
            this.modalValue = changeValue;
810
        } else {
811
            if (changeValue.length === 0) {
812
                this.modalValue = null;
813
            } else {
814
                this.modalValue = changeValue[0];
815
            }
816
        }
817
        this.onChangeFn(this.modalValue);
818
        this.updateCdkConnectedOverlayPositions();
819
    }
820

821
    private highlightCorrectOption(fromOpenPanel: boolean = true): void {
822
        if (this.keyManager && this.panelOpen) {
823
            if (fromOpenPanel) {
824
                if (this.keyManager.activeItem) {
825
                    return;
826
                }
827
                if (this.empty) {
828
                    if (!this.thyAutoActiveFirstItem) {
829
                        return;
830
                    }
831
                    this.keyManager.setFirstItemActive();
832
                } else {
833
                    this.keyManager.setActiveItem(this.selectionModel.selected[0]);
834
                }
835
            } else {
836
                if (!this.thyAutoActiveFirstItem) {
837
                    return;
838
                }
839
                // always set first option active
840
                this.keyManager.setFirstItemActive();
841
            }
842
        }
843
    }
844

845
    private initKeyManager() {
846
        if (this.keyManager && this.keyManager.activeItem) {
847
            this.keyManager.activeItem.setInactiveStyles();
848
        }
849
        this.keyManager = new ActiveDescendantKeyManager<ThyOption>(this.options)
850
            .withTypeAhead()
851
            .withWrap()
852
            .withVerticalOrientation()
853
            .withAllowedModifierKeys(['shiftKey']);
854

855
        this.keyManager.tabOut.pipe(takeUntil(this.destroy$)).subscribe(() => {
856
            this.focus();
857
            this.close();
858
        });
859
        this.keyManager.change.pipe(takeUntil(this.destroy$)).subscribe(() => {
860
            if (this.panelOpen && this.panel) {
861
                if (this.keyManager.activeItem) {
862
                    ScrollToService.scrollToElement(this.keyManager.activeItem.element.nativeElement, this.panel.nativeElement);
863
                }
864
            } else if (!this.panelOpen && !this.isMultiple && this.keyManager.activeItem) {
865
                this.keyManager.activeItem.selectViaInteraction();
866
            }
867
        });
868
    }
869

870
    private handleClosedKeydown(event: KeyboardEvent): void {
871
        const keyCode = event.keyCode;
872
        const isArrowKey = keyCode === DOWN_ARROW || keyCode === UP_ARROW || keyCode === LEFT_ARROW || keyCode === RIGHT_ARROW;
873
        const isOpenKey = keyCode === ENTER || keyCode === SPACE;
874
        const manager = this.keyManager;
875

876
        // Open the select on ALT + arrow key to match the native <select>
877
        if ((isOpenKey && !hasModifierKey(event)) || ((this.isMultiple || event.altKey) && isArrowKey)) {
878
            event.preventDefault(); // prevents the page from scrolling down when pressing space
879
            this.open();
880
        } else if (!this.isMultiple) {
881
            if (keyCode === HOME || keyCode === END) {
882
                keyCode === HOME ? manager.setFirstItemActive() : manager.setLastItemActive();
883
                event.preventDefault();
884
            } else {
885
                manager.onKeydown(event);
886
            }
887
        }
888
    }
889

890
    private handleOpenKeydown(event: KeyboardEvent): void {
891
        const keyCode = event.keyCode;
892
        const isArrowKey = keyCode === DOWN_ARROW || keyCode === UP_ARROW;
893
        const manager = this.keyManager;
894

895
        if (keyCode === HOME || keyCode === END) {
896
            event.preventDefault();
897
            keyCode === HOME ? manager.setFirstItemActive() : manager.setLastItemActive();
898
        } else if (isArrowKey && event.altKey) {
899
            // Close the select on ALT + arrow key to match the native <select>
900
            event.preventDefault();
901
            this.close();
902
        } else if ((keyCode === ENTER || keyCode === SPACE) && (manager.activeItem || !this.empty) && !hasModifierKey(event)) {
903
            event.preventDefault();
904
            if (!manager.activeItem) {
905
                if (manager.activeItemIndex === -1 && !this.empty) {
906
                    manager.setActiveItem(this.selectionModel.selected[0]);
907
                }
908
            }
909
            manager.activeItem.selectViaInteraction();
910
        } else if (this.isMultiple && keyCode === A && event.ctrlKey) {
911
            event.preventDefault();
912
            const hasDeselectedOptions = this.options.some(opt => !opt.disabled && !opt.selected);
913

914
            this.options.forEach(option => {
915
                if (!option.disabled) {
916
                    hasDeselectedOptions ? option.select() : option.deselect();
917
                }
918
            });
919
        } else {
920
            if (manager.activeItemIndex === -1 && !this.empty) {
921
                manager.setActiveItem(this.selectionModel.selected[0]);
922
            }
923
            const previouslyFocusedIndex = manager.activeItemIndex;
924

925
            manager.onKeydown(event);
926

927
            if (
928
                this.isMultiple &&
929
                isArrowKey &&
930
                event.shiftKey &&
931
                manager.activeItem &&
932
                manager.activeItemIndex !== previouslyFocusedIndex
933
            ) {
934
                manager.activeItem.selectViaInteraction();
935
            }
936
        }
937
    }
938

939
    private getPositions() {
940
        this.dropDownPositions = getFlexiblePositions(this.thyPlacement || this.config.placement, this.defaultOffset);
941
    }
942

943
    private instanceSelectionModel() {
944
        if (this.selectionModel) {
945
            this.selectionModel.clear();
946
        }
947
        this.selectionModel = new SelectionModel<ThyOption>(this.isMultiple);
948
        if (this.selectionModelSubscription) {
949
            this.selectionModelSubscription.unsubscribe();
950
            this.selectionModelSubscription = null;
951
        }
952
        this.selectionModelSubscription = this.selectionModel.changed.pipe(takeUntil(this.destroy$)).subscribe(event => {
953
            event.added.forEach(option => option.select());
954
            event.removed.forEach(option => option.deselect());
955
        });
956
    }
957

958
    private resetOptions() {
959
        const changedOrDestroyed$ = merge(this.optionsChanges$, this.destroy$);
960

961
        this.optionSelectionChanges.pipe(takeUntil(changedOrDestroyed$)).subscribe((event: ThyOptionSelectionChangeEvent) => {
962
            this.onSelect(event.option, event.isUserInput);
963
            if (event.isUserInput && !this.isMultiple && this.panelOpen) {
964
                this.close();
965
                this.focus();
966
            }
967
        });
968
    }
969

970
    private initializeSelection() {
971
        Promise.resolve().then(() => {
972
            this.setSelectionByModelValue(this.modalValue);
973
        });
974
    }
975

976
    private setDropDownClass() {
977
        let modeClass = '';
978
        if (this.isMultiple) {
979
            modeClass = `thy-select-dropdown-${this.mode}`;
980
        } else {
981
            modeClass = `thy-select-dropdown-single`;
982
        }
983
        this.dropDownClass = {
984
            [`thy-select-dropdown`]: true,
985
            [modeClass]: true
986
        };
987
    }
988

989
    private setSelectionByModelValue(modalValue: any) {
990
        if (helpers.isUndefinedOrNull(modalValue)) {
991
            if (this.selectionModel.selected.length > 0) {
992
                this.selectionModel.clear();
993
                this.changeDetectorRef.markForCheck();
994
            }
995
            return;
996
        }
997
        if (this.isMultiple) {
998
            if (isArray(modalValue)) {
999
                const selected = [...this.selectionModel.selected];
1000
                this.selectionModel.clear();
1001
                (modalValue as Array<any>).forEach(itemValue => {
1002
                    const option =
1003
                        this.options.find(_option => _option.thyValue === itemValue) ||
1004
                        selected.find(_option => _option.thyValue === itemValue);
1005
                    if (option) {
1006
                        this.selectionModel.select(option);
1007
                    }
1008
                });
1009
            }
1010
        } else {
1011
            const selectedOption = this.options?.find(option => {
1012
                return option.thyValue === modalValue;
1013
            });
1014
            if (selectedOption) {
1015
                this.selectionModel.select(selectedOption);
1016
            }
1017
        }
1018
        this.changeDetectorRef.markForCheck();
1019
    }
1020

1021
    private onSelect(option: ThyOption, isUserInput: boolean) {
1022
        const wasSelected = this.selectionModel.isSelected(option);
1023

1024
        if (option.thyValue == null && !this.isMultiple) {
1025
            option.deselect();
1026
            this.selectionModel.clear();
1027
        } else {
1028
            if (wasSelected !== option.selected) {
1029
                option.selected ? this.selectionModel.select(option) : this.selectionModel.deselect(option);
1030
            }
1031

1032
            if (isUserInput) {
1033
                this.keyManager.setActiveItem(option);
1034
            }
1035

1036
            if (this.isMultiple) {
1037
                this.sortValues();
1038
                if (isUserInput) {
1039
                    this.focus();
1040
                }
1041
            }
1042
        }
1043

1044
        if (wasSelected !== this.selectionModel.isSelected(option)) {
1045
            this.emitModelValueChange();
1046
        }
1047
        if (!this.isMultiple) {
1048
            this.onTouchedFn();
1049
        }
1050
        this.changeDetectorRef.markForCheck();
1051
    }
1052

1053
    private sortValues() {
1054
        if (this.isMultiple) {
1055
            const options = this.options.toArray();
1056

1057
            if (this.thySortComparator) {
1058
                this.selectionModel.sort((a, b) => {
1059
                    return this.thySortComparator(a, b, options);
1060
                });
1061
            }
1062
        }
1063
    }
1064

1065
    private getOriginRectWidth() {
1066
        return this.thyOrigin ? coerceElement(this.thyOrigin).offsetWidth : this.trigger.nativeElement.offsetWidth;
1067
    }
1068

1069
    private subscribeTriggerResize(): void {
1070
        this.unsubscribeTriggerResize();
1071
        this.ngZone.runOutsideAngular(() => {
1072
            this.resizeSubscription = new Observable<number>(observer => {
1073
                const resize = new ResizeObserver((entries: ResizeObserverEntry[]) => {
1074
                    observer.next(null);
1075
                });
1076
                resize.observe(this.trigger.nativeElement);
1077
            })
1078
                .pipe(
1079
                    startWith(),
1080
                    map(() => {
1081
                        return this.getOriginRectWidth();
1082
                    }),
1083
                    distinctUntilChanged()
1084
                )
1085
                .subscribe((width: number) => {
1086
                    this.ngZone.run(() => {
1087
                        this.triggerRectWidth = width;
1088
                        this.updateCdkConnectedOverlayPositions();
1089
                        this.changeDetectorRef.markForCheck();
1090
                    });
1091
                });
1092
        });
1093
    }
1094

1095
    private unsubscribeTriggerResize(): void {
1096
        if (this.resizeSubscription) {
1097
            this.resizeSubscription.unsubscribe();
1098
            this.resizeSubscription = null;
1099
        }
1100
    }
1101

1102
    ngOnDestroy() {
1103
        this.unsubscribeTriggerResize();
1104
        this.destroy$.next();
1105
        this.destroy$.complete();
1106
    }
1107
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc