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

atinc / ngx-tethys / 5fa9630c-19a1-4ee7-af3d-6a0c3535952a

08 Oct 2024 08:24AM UTC coverage: 90.438% (+0.007%) from 90.431%
5fa9630c-19a1-4ee7-af3d-6a0c3535952a

push

circleci

minlovehua
refactor: refactor all control-flow directives to new control-flow #TINFR-381

5511 of 6738 branches covered (81.79%)

Branch coverage included in aggregate %.

98 of 104 new or added lines in 58 files covered. (94.23%)

113 existing lines in 17 files now uncovered.

13253 of 14010 relevant lines covered (94.6%)

991.73 hits per line

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

92.15
/src/select/custom-select/custom-select.component.ts
1
import {
2
    getFlexiblePositions,
3
    scaleMotion,
4
    scaleXMotion,
5
    scaleYMotion,
6
    ScrollToService,
7
    TabIndexDisabledControlValueAccessorMixin,
8
    ThyClickDispatcher,
9
    ThyPlacement
10
} from 'ngx-tethys/core';
11
import { ThyEmpty } from 'ngx-tethys/empty';
12
import { ThyLoading } from 'ngx-tethys/loading';
13
import {
14
    IThyOptionParentComponent,
15
    SelectControlSize,
16
    THY_OPTION_PARENT_COMPONENT,
17
    ThyOption,
18
    ThyOptionsContainer,
1✔
19
    ThyOptionSelectionChangeEvent,
1✔
20
    ThyScrollDirective,
1✔
21
    ThySelectControl,
1✔
22
    ThySelectOptionGroup,
1✔
23
    ThyStopPropagationDirective
1✔
24
} from 'ngx-tethys/shared';
25
import {
26
    A,
27
    coerceBooleanProperty,
28
    DOWN_ARROW,
29
    elementMatchClosest,
1✔
30
    END,
31
    ENTER,
78✔
32
    FunctionProp,
78✔
33
    hasModifierKey,
78✔
34
    helpers,
78✔
35
    HOME,
36
    isArray,
37
    isFunction,
1✔
38
    LEFT_ARROW,
39
    RIGHT_ARROW,
40
    SPACE,
447✔
41
    UP_ARROW
42
} from 'ngx-tethys/util';
43
import { defer, merge, Observable, Subject, Subscription, timer } from 'rxjs';
114✔
44
import { distinctUntilChanged, filter, map, startWith, switchMap, take, takeUntil } from 'rxjs/operators';
45

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

16✔
80
import {
16✔
81
    DEFAULT_SELECT_CONFIG,
16✔
82
    THY_SELECT_CONFIG,
83
    THY_SELECT_SCROLL_STRATEGY,
84
    ThyDropdownWidthMode,
85
    ThySelectConfig
172!
86
} from '../select.config';
172✔
87

88
export type SelectMode = 'multiple' | '';
89

×
90
export type ThySelectTriggerType = 'click' | 'hover';
91

92
export const SELECT_PANEL_MAX_HEIGHT = 300;
93

1,520✔
94
export const SELECT_OPTION_MAX_HEIGHT = 40;
95

96
export const SELECT_OPTION_GROUP_MAX_HEIGHT = 30;
172✔
97

172✔
98
export const SELECT_PANEL_PADDING_TOP = 10;
172✔
99

172✔
100
export const THY_SELECT_PANEL_MIN_WIDTH = 200;
172✔
101

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

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

172✔
118
interface ThyOptionGroupModel extends ThySelectOptionModel {
172✔
119
    children?: ThySelectOptionModel[];
179!
120
}
876✔
121

122
const noop = () => {};
×
123

124
/**
172✔
125
 * 下拉选择组件
172✔
126
 * @name thy-select,thy-custom-select
172✔
127
 * @order 10
172✔
128
 */
172✔
129
@Component({
172✔
130
    selector: 'thy-select,thy-custom-select',
172✔
131
    templateUrl: './custom-select.component.html',
172✔
132
    exportAs: 'thySelect',
172✔
133
    providers: [
172✔
134
        {
172✔
135
            provide: THY_OPTION_PARENT_COMPONENT,
172✔
136
            useExisting: ThySelect
172✔
137
        },
172✔
138
        {
172✔
139
            provide: NG_VALUE_ACCESSOR,
172✔
140
            useExisting: forwardRef(() => ThySelect),
172✔
141
            multi: true
172✔
142
        }
172✔
143
    ],
172✔
144
    changeDetection: ChangeDetectionStrategy.OnPush,
172✔
145
    standalone: true,
146
    imports: [
147
        CdkOverlayOrigin,
247✔
148
        ThySelectControl,
247✔
149
        CdkConnectedOverlay,
150
        ThyStopPropagationDirective,
151
        NgClass,
188✔
152
        ThyScrollDirective,
188✔
153
        ThyLoading,
188✔
154
        ThyEmpty,
100✔
155
        ThyOptionsContainer,
156
        ThyOption,
188✔
157
        ThySelectOptionGroup,
188!
158
        NgTemplateOutlet
188✔
159
    ],
160
    host: {
161
        '[attr.tabindex]': 'tabIndex',
162
        '(focus)': 'onFocus($event)',
198✔
163
        '(blur)': 'onBlur($event)'
4✔
164
    },
4✔
165
    animations: [scaleXMotion, scaleYMotion, scaleMotion]
4✔
166
})
167
export class ThySelect
168
    extends TabIndexDisabledControlValueAccessorMixin
169
    implements ControlValueAccessor, IThyOptionParentComponent, OnInit, AfterViewInit, AfterContentInit, OnDestroy
170
{
171
    disabled = false;
172

1✔
173
    size: SelectControlSize;
3✔
174

1✔
175
    mode: SelectMode = '';
1✔
176

3✔
177
    emptyStateText = '暂无可选项';
1✔
178

179
    emptySearchMessageText = '暂无可选项';
180

181
    scrollTop = 0;
1✔
182

183
    modalValue: any = null;
1✔
184

3✔
185
    defaultOffset = 4;
2✔
186

2✔
187
    dropDownClass: { [key: string]: boolean };
1✔
188

1✔
189
    dropDownMinWidth: number | null = null;
190

191
    /**
192
     * 设置下拉框的最小宽度,默认值 `match-select`,表示与输入框的宽度一致;`min-width` 表示最小宽度为200px;支持自定义最小宽度,比如传 `{minWidth: 150}` 表示最小宽度为150px
1✔
193
     * @default match-select
194
     */
195
    @Input() thyDropdownWidthMode: ThyDropdownWidthMode;
1✔
196

197
    public dropDownPositions: ConnectionPositionPair[];
198

12✔
199
    public selectionModel: SelectionModel<ThyOption>;
1✔
200

201
    public triggerRectWidth: number;
202

3✔
203
    public scrollStrategy: ScrollStrategy;
204

205
    private resizeSubscription: Subscription;
206

188✔
207
    private selectionModelSubscription: Subscription;
188✔
208

188✔
209
    /**
1✔
210
     * 手动聚焦中的标识
211
     */
187✔
212
    private manualFocusing = false;
4✔
213

214
    private config: ThySelectConfig;
215

183✔
216
    private readonly destroy$ = new Subject<void>();
217

188✔
218
    readonly optionSelectionChanges: Observable<ThyOptionSelectionChangeEvent> = defer(() => {
219
        if (this.options) {
220
            return merge(...this.options.map(option => option.selectionChange));
171✔
221
        }
3✔
222
        return this.ngZone.onStable.asObservable().pipe(
223
            take(1),
224
            switchMap(() => this.optionSelectionChanges)
225
        );
171✔
226
    }) as Observable<ThyOptionSelectionChangeEvent>;
168✔
227

228
    @ViewChild(CdkConnectedOverlay, { static: true }) cdkConnectedOverlay: CdkConnectedOverlay;
229

230
    @HostBinding('class.thy-select-custom') isSelectCustom = true;
171✔
231

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

179✔
234
    keyManager: ActiveDescendantKeyManager<ThyOption>;
179✔
235

1✔
236
    @HostBinding('class.menu-is-opened')
1✔
237
    panelOpen = false;
238

179✔
239
    /**
179✔
240
     * 搜索时回调
241
     */
242
    @Output() thyOnSearch: EventEmitter<string> = new EventEmitter<string>();
243

179✔
244
    /**
42✔
245
     * 下拉菜单滚动到底部事件,可以用这个事件实现滚动加载
246
     */
247
    @Output() thyOnScrollToBottom: EventEmitter<void> = new EventEmitter<void>();
248

171✔
249
    /**
1✔
250
     * 下拉菜单展开和折叠状态事件
1✔
251
     */
1✔
252
    @Output() thyOnExpandStatusChange: EventEmitter<boolean> = new EventEmitter<boolean>();
1✔
253

254
    /**
255
     * 下拉列表是否显示搜索框
256
     * @default false
257
     */
249✔
258
    @Input({ transform: coerceBooleanProperty }) thyShowSearch: boolean;
259

260
    /**
80✔
261
     * 选择框默认文字
74✔
262
     */
64✔
263
    @Input() thyPlaceHolder: string;
62✔
264

62✔
265
    /**
266
     * 是否使用服务端搜索,当为 true 时,将不再在前端进行过滤
267
     * @default false
2!
UNCOV
268
     */
×
UNCOV
269
    @Input({ transform: coerceBooleanProperty }) thyServerSearch: boolean;
×
270

271
    /**
272
     * 异步加载 loading 状态,false 表示加载中,true 表示加载完成
273
     */
274
    @Input({ transform: coerceBooleanProperty }) thyLoadState = true;
275

276
    /**
1!
277
     * 是否自动设置选项第一条为高亮状态
1✔
278
     */
279
    @Input({ transform: coerceBooleanProperty }) thyAutoActiveFirstItem = true;
280

281
    /**
1✔
282
     * 下拉选择模式
1!
283
     * @type 'multiple' | ''
1!
284
     */
1✔
285
    @Input()
1✔
286
    set thyMode(value: SelectMode) {
287
        this.mode = value;
288
        this.instanceSelectionModel();
289
        this.getPositions();
290
        this.setDropDownClass();
291
    }
11✔
292

11✔
293
    get thyMode(): SelectMode {
2✔
294
        return this.mode;
2✔
295
    }
296

297
    /**
9✔
298
     * 操作图标类型
9✔
299
     * @type primary | success | danger | warning
52✔
300
     * @default primary
12✔
301
     */
302
    @Input()
303
    get thySize(): SelectControlSize {
40✔
304
        return this.size;
305
    }
306
    set thySize(value: SelectControlSize) {
9✔
307
        this.size = value;
9✔
308
    }
309

310
    /**
311
     * 数据为空时显示的提示文字
312
     */
2✔
313
    @Input()
1✔
314
    set thyEmptyStateText(value: string) {
315
        this.emptyStateText = value;
1✔
316
    }
317

318
    /**
319
     * 搜索结果为空时显示的提示文字
320
     */
24!
321
    @Input()
322
    set thyEmptySearchMessageText(value: string) {
323
        this.emptySearchMessageText = value;
3✔
324
    }
3✔
325

326
    /**
24✔
327
     * 滚动加载是否可用,只能当这个参数可以,下面的thyOnScrollToBottom事件才会触发
328
     */
329
    @Input({ transform: coerceBooleanProperty })
28✔
330
    thyEnableScrollLoad = false;
28✔
331

28✔
332
    /**
333
     * 单选( thyMode="" 或者不设置)时,选择框支持清除
334
     */
4✔
335
    @Input({ transform: coerceBooleanProperty }) thyAllowClear = false;
4✔
336

1✔
337
    /**
338
     * 是否禁用
18✔
339
     * @default false
1✔
340
     */
341
    @Input({ transform: coerceBooleanProperty })
1✔
342
    set thyDisabled(value: boolean) {
343
        this.disabled = value;
344
    }
2✔
345
    get thyDisabled(): boolean {
346
        return this.disabled;
347
    }
348

4✔
349
    /**
3✔
350
     * 排序比较函数
351
     */
4✔
352
    @Input() thySortComparator: (a: ThyOption, b: ThyOption, options: ThyOption[]) => number;
1✔
353

354
    /**
3✔
355
     * Footer 模板,默认值为空不显示 Footer
3✔
356
     * @type TemplateRef
3✔
357
     */
358
    @Input()
359
    thyFooterTemplate: TemplateRef<any>;
56✔
360

56✔
361
    /**
43✔
362
     * 弹出位置
363
     * @type ThyPlacement
364
     */
365
    @Input()
366
    thyPlacement: ThyPlacement;
449✔
367

368
    /**
369
     * 自定义 Overlay Origin
1,932✔
370
     */
371
    @Input()
372
    thyOrigin: ElementRef | HTMLElement;
80✔
373

374
    /**
375
     * 自定义 Footer 模板容器 class
1!
376
     */
1✔
377
    @Input()
378
    thyFooterClass = 'thy-custom-select-footer';
379

90✔
380
    /**
10✔
381
     * @private
9✔
382
     */
383
    @ContentChild('selectedDisplay') selectedValueDisplayRef: TemplateRef<any>;
384

385
    /**
80✔
386
     * 初始化时,是否展开面板
387
     * @default false
388
     */
389
    @Input({ transform: coerceBooleanProperty }) thyAutoExpand: boolean;
83✔
390

2✔
391
    /**
392
     * 是否弹出透明遮罩,如果显示遮罩则会阻止滚动区域滚动
81✔
393
     */
81✔
394
    @Input({ transform: coerceBooleanProperty })
81✔
395
    thyHasBackdrop = false;
81✔
396

81✔
397
    /**
81✔
398
     * 设置多选时最大显示的标签数量,0 表示不限制
399
     */
400
    @Input({ transform: numberAttribute }) thyMaxTagCount = 0;
57✔
401

30✔
402
    /**
30✔
403
     * 是否隐藏选择框边框
30✔
404
     * @default false
30✔
405
     */
30✔
406
    @Input({ transform: coerceBooleanProperty }) thyBorderless = false;
407

408
    isReactiveDriven = false;
409

47✔
410
    innerOptions: ThySelectOptionModel[];
47✔
411

66✔
412
    optionGroups: ThyOptionGroupModel[] = [];
413

47✔
414
    /**
25✔
415
     * option 列表
416
     * @type ThySelectOptionModel[]
417
     */
22✔
418
    @Input()
2✔
419
    set thyOptions(value: ThySelectOptionModel[]) {
420
        if (value === null) {
421
            value = [];
20✔
422
        }
423
        this.innerOptions = value;
424
        this.isReactiveDriven = true;
47✔
425
        this.buildReactiveOptions();
47✔
426
    }
427

81✔
428
    options: QueryList<ThyOption>;
91!
429

91✔
430
    /**
81✔
431
     * 目前只支持多选选中项的展示,默认为空,渲染文字模板,传入tag,渲染展示模板,
5✔
432
     * @default ''|tag
433
     */
76✔
434
    @Input() thyPreset: string = '';
75✔
435

1✔
436
    @ViewChild('trigger', { read: ElementRef, static: true }) trigger: ElementRef<HTMLElement>;
437

74✔
438
    @ViewChild('panel', { read: ElementRef }) panel: ElementRef<HTMLElement>;
439

440
    /**
1✔
441
     * @private
442
     */
443
    @ContentChildren(ThyOption, { descendants: true }) contentOptions: QueryList<ThyOption>;
444

10!
UNCOV
445
    @ViewChildren(ThyOption) viewOptions: QueryList<ThyOption>;
×
446

447
    /**
448
     * @private
10✔
449
     */
450
    @ContentChildren(ThySelectOptionGroup) contentGroups: QueryList<ThySelectOptionGroup>;
451

452
    @ViewChildren(ThySelectOptionGroup) viewGroups: QueryList<ThySelectOptionGroup>;
453

179✔
454
    @HostListener('keydown', ['$event'])
2✔
455
    handleKeydown(event: KeyboardEvent): void {
456
        if (!this.disabled) {
179✔
457
            if (event.keyCode === ENTER) {
458
                event.stopPropagation();
459
            }
460
            this.panelOpen ? this.handleOpenKeydown(event) : this.handleClosedKeydown(event);
461
        }
179✔
UNCOV
462
    }
×
UNCOV
463

×
464
    get optionsChanges$() {
465
        this.options = this.isReactiveDriven ? this.viewOptions : this.contentOptions;
179✔
466
        let previousOptions: ThyOption[] = this.options.toArray();
90✔
467
        return this.options.changes.pipe(
19✔
468
            map(data => {
18✔
469
                return this.options.toArray();
470
            }),
471
            filter(data => {
71✔
472
                const res = previousOptions.length !== data.length || previousOptions.some((op, index) => op !== data[index]);
5✔
473
                previousOptions = data;
474
                return res;
475
            })
476
        );
477
    }
4✔
478

4✔
479
    private buildScrollStrategy() {
4✔
480
        if (this.scrollStrategyFactory && isFunction(this.scrollStrategyFactory)) {
4✔
481
            this.scrollStrategy = this.scrollStrategyFactory();
482
        } else {
4!
483
            this.scrollStrategy = this.overlay.scrollStrategies.reposition();
1✔
484
        }
1✔
485
    }
486

3!
487
    private isSearching = false;
3✔
488

2✔
489
    groupBy = (item: ThySelectOptionModel) => item.groupLabel;
2✔
490

491
    get placement(): ThyPlacement {
492
        return this.thyPlacement || this.config.placement;
1✔
493
    }
494

495
    constructor(
496
        private ngZone: NgZone,
497
        private elementRef: ElementRef,
13✔
498
        private changeDetectorRef: ChangeDetectorRef,
13✔
499
        private overlay: Overlay,
13✔
500
        private thyClickDispatcher: ThyClickDispatcher,
13✔
501
        @Inject(PLATFORM_ID) private platformId: string,
2✔
502
        @Optional() @Inject(THY_SELECT_SCROLL_STRATEGY) public scrollStrategyFactory: FunctionProp<ScrollStrategy>,
2✔
503
        @Optional() @Inject(THY_SELECT_CONFIG) public selectConfig: ThySelectConfig
504
    ) {
11✔
505
        super();
506
        this.config = { ...DEFAULT_SELECT_CONFIG, ...selectConfig };
1✔
507
        this.buildScrollStrategy();
1✔
508
    }
509

10✔
510
    writeValue(value: any): void {
3✔
511
        this.modalValue = value;
3!
UNCOV
512
        this.setSelectionByModelValue(this.modalValue);
×
UNCOV
513
    }
×
514

515
    ngOnInit() {
516
        this.getPositions();
3✔
517
        this.dropDownMinWidth = this.getDropdownMinWidth();
518
        if (!this.selectionModel) {
7✔
519
            this.instanceSelectionModel();
1✔
520
        }
1✔
521
        this.setDropDownClass();
1✔
522

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

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

104✔
564
    buildReactiveOptions() {
14✔
565
        if (this.innerOptions.filter(item => this.groupBy(item)).length > 0) {
14✔
566
            this.optionGroups = this.buildOptionGroups(this.innerOptions);
567
        } else {
568
            this.optionGroups = this.innerOptions;
569
        }
570
    }
179✔
571

179✔
572
    getDropdownMinWidth(): number | null {
573
        const mode = this.thyDropdownWidthMode || this.config.dropdownWidthMode;
574
        let dropdownMinWidth: number | null = null;
575

266✔
576
        if ((mode as { minWidth: number })?.minWidth) {
266✔
577
            dropdownMinWidth = (mode as { minWidth: number }).minWidth;
43✔
578
        } else if (mode === 'min-width') {
579
            dropdownMinWidth = THY_SELECT_PANEL_MIN_WIDTH;
580
        } else {
223✔
581
            dropdownMinWidth = null;
582
        }
266✔
583

584
        return dropdownMinWidth;
585
    }
586

587
    ngAfterViewInit(): void {
588
        if (this.isReactiveDriven) {
426✔
589
            this.setup();
236✔
590
        }
1✔
591
    }
1✔
592

593
    ngAfterContentInit() {
236✔
594
        if (!this.isReactiveDriven) {
595
            this.setup();
190✔
596
        }
28!
597
    }
28✔
598

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

619
        if (this.thyAutoExpand) {
105✔
620
            timer(0).subscribe(() => {
105!
UNCOV
621
                this.changeDetectorRef.markForCheck();
×
UNCOV
622
                this.open();
×
623
                this.focus();
624
            });
625
        }
105✔
626
    }
44✔
627

628
    public get isHiddenOptions(): boolean {
105✔
629
        return this.options.toArray().every(option => option.hidden);
33✔
630
    }
631

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

56!
648
    public dropDownMouseMove(event: MouseEvent) {
56✔
649
        if (this.keyManager.activeItem) {
56✔
650
            this.keyManager.setActiveItem(-1);
2✔
651
        }
1✔
652
    }
653

654
    public onOptionsScrolled(elementRef: ElementRef) {
655
        const scroll = elementRef.nativeElement.scrollTop,
656
            height = elementRef.nativeElement.clientHeight,
657
            scrollHeight = elementRef.nativeElement.scrollHeight;
81✔
658

659
        if (scroll + height + 10 >= scrollHeight) {
660
            if (this.thyOnScrollToBottom.observers.length > 0) {
81✔
661
                this.ngZone.run(() => {
81✔
662
                    this.thyOnScrollToBottom.emit();
81✔
663
                });
81✔
664
            }
81✔
665
        }
666
    }
81✔
667

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

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

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

709
    public focus(options?: FocusOptions): void {
710
        this.manualFocusing = true;
711
        this.elementRef.nativeElement.focus(options);
712
        this.manualFocusing = false;
713
    }
714

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

729
    public clearSelectValue(event?: Event) {
730
        if (event) {
731
            event.stopPropagation();
732
        }
733
        if (this.disabled) {
734
            return;
735
        }
736
        this.selectionModel.clear();
737
        this.changeDetectorRef.markForCheck();
738
        this.emitModelValueChange();
739
    }
740

741
    public updateCdkConnectedOverlayPositions(): void {
742
        setTimeout(() => {
743
            if (this.cdkConnectedOverlay && this.cdkConnectedOverlay.overlayRef) {
1✔
744
                this.cdkConnectedOverlay.overlayRef.updatePosition();
745
            }
746
        });
747
    }
748

749
    public get selected(): ThyOption | ThyOption[] {
750
        return this.isMultiple ? this.selectionModel.selected : this.selectionModel.selected[0];
751
    }
752

753
    public get isMultiple(): boolean {
754
        return this.mode === 'multiple';
755
    }
156✔
756

757
    public get empty(): boolean {
758
        return !this.selectionModel || this.selectionModel.isEmpty();
759
    }
760

761
    public getItemCount(): number {
762
        const group = this.isReactiveDriven ? this.viewGroups : this.contentGroups;
763
        return this.options.length + group.length;
764
    }
765

766
    public toggle(event: MouseEvent): void {
767
        if (this.panelOpen) {
768
            if (!this.thyShowSearch) {
769
                this.close();
770
            }
771
        } else {
772
            this.open();
773
        }
774
    }
775

776
    public open(): void {
777
        if (this.disabled || !this.options || this.panelOpen) {
778
            return;
779
        }
780
        this.triggerRectWidth = this.getOriginRectWidth();
781
        this.subscribeTriggerResize();
782
        this.panelOpen = true;
783
        this.highlightCorrectOption();
784
        this.thyOnExpandStatusChange.emit(this.panelOpen);
785
        this.changeDetectorRef.markForCheck();
786
    }
787

788
    public close(): void {
789
        if (this.panelOpen) {
790
            this.panelOpen = false;
791
            this.unsubscribeTriggerResize();
792
            this.thyOnExpandStatusChange.emit(this.panelOpen);
793
            this.changeDetectorRef.markForCheck();
794
            this.onTouchedFn();
795
        }
796
    }
797

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

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

840
    private initKeyManager() {
841
        if (this.keyManager && this.keyManager.activeItem) {
842
            this.keyManager.activeItem.setInactiveStyles();
843
        }
844
        this.keyManager = new ActiveDescendantKeyManager<ThyOption>(this.options)
845
            .withTypeAhead()
846
            .withWrap()
847
            .withVerticalOrientation()
848
            .withAllowedModifierKeys(['shiftKey']);
849

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

865
    private handleClosedKeydown(event: KeyboardEvent): void {
866
        const keyCode = event.keyCode;
867
        const isArrowKey = keyCode === DOWN_ARROW || keyCode === UP_ARROW || keyCode === LEFT_ARROW || keyCode === RIGHT_ARROW;
868
        const isOpenKey = keyCode === ENTER || keyCode === SPACE;
869
        const manager = this.keyManager;
870

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

885
    private handleOpenKeydown(event: KeyboardEvent): void {
886
        const keyCode = event.keyCode;
887
        const isArrowKey = keyCode === DOWN_ARROW || keyCode === UP_ARROW;
888
        const manager = this.keyManager;
889

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

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

920
            manager.onKeydown(event);
921

922
            if (
923
                this.isMultiple &&
924
                isArrowKey &&
925
                event.shiftKey &&
926
                manager.activeItem &&
927
                manager.activeItemIndex !== previouslyFocusedIndex
928
            ) {
929
                manager.activeItem.selectViaInteraction();
930
            }
931
        }
932
    }
933

934
    private getPositions() {
935
        this.dropDownPositions = getFlexiblePositions(this.thyPlacement || this.config.placement, this.defaultOffset);
936
    }
937

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

953
    private resetOptions() {
954
        const changedOrDestroyed$ = merge(this.optionsChanges$, this.destroy$);
955

956
        this.optionSelectionChanges.pipe(takeUntil(changedOrDestroyed$)).subscribe((event: ThyOptionSelectionChangeEvent) => {
957
            this.onSelect(event.option, event.isUserInput);
958
            if (event.isUserInput && !this.isMultiple && this.panelOpen) {
959
                this.close();
960
                this.focus();
961
            }
962
        });
963
    }
964

965
    private initializeSelection() {
966
        Promise.resolve().then(() => {
967
            this.setSelectionByModelValue(this.modalValue);
968
        });
969
    }
970

971
    private setDropDownClass() {
972
        let modeClass = '';
973
        if (this.isMultiple) {
974
            modeClass = `thy-select-dropdown-${this.mode}`;
975
        } else {
976
            modeClass = `thy-select-dropdown-single`;
977
        }
978
        this.dropDownClass = {
979
            [`thy-select-dropdown`]: true,
980
            [modeClass]: true
981
        };
982
    }
983

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

1016
    private onSelect(option: ThyOption, isUserInput: boolean) {
1017
        const wasSelected = this.selectionModel.isSelected(option);
1018

1019
        if (option.thyValue == null && !this.isMultiple) {
1020
            option.deselect();
1021
            this.selectionModel.clear();
1022
        } else {
1023
            if (wasSelected !== option.selected) {
1024
                option.selected ? this.selectionModel.select(option) : this.selectionModel.deselect(option);
1025
            }
1026

1027
            if (isUserInput) {
1028
                this.keyManager.setActiveItem(option);
1029
            }
1030

1031
            if (this.isMultiple) {
1032
                this.sortValues();
1033
                if (isUserInput) {
1034
                    this.focus();
1035
                }
1036
            }
1037
        }
1038

1039
        if (wasSelected !== this.selectionModel.isSelected(option)) {
1040
            this.emitModelValueChange();
1041
        }
1042
        if (!this.isMultiple) {
1043
            this.onTouchedFn();
1044
        }
1045
        this.changeDetectorRef.markForCheck();
1046
    }
1047

1048
    private sortValues() {
1049
        if (this.isMultiple) {
1050
            const options = this.options.toArray();
1051

1052
            if (this.thySortComparator) {
1053
                this.selectionModel.sort((a, b) => {
1054
                    return this.thySortComparator(a, b, options);
1055
                });
1056
            }
1057
        }
1058
    }
1059

1060
    private getOriginRectWidth() {
1061
        return this.thyOrigin ? coerceElement(this.thyOrigin).offsetWidth : this.trigger.nativeElement.offsetWidth;
1062
    }
1063

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

1090
    private unsubscribeTriggerResize(): void {
1091
        if (this.resizeSubscription) {
1092
            this.resizeSubscription.unsubscribe();
1093
            this.resizeSubscription = null;
1094
        }
1095
    }
1096

1097
    ngOnDestroy() {
1098
        this.unsubscribeTriggerResize();
1099
        this.destroy$.next();
1100
        this.destroy$.complete();
1101
    }
1102
}
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