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

atinc / ngx-tethys / 881c8997-29c3-4d01-9ef1-22092f16cec2

03 Apr 2024 03:31AM UTC coverage: 90.404% (-0.2%) from 90.585%
881c8997-29c3-4d01-9ef1-22092f16cec2

Pull #3062

circleci

minlovehua
refactor(all): use the transform attribute of @input() instead of @InputBoolean() and @InputNumber()
Pull Request #3062: refactor(all): use the transform attribute of @input() instead of @InputBoolean() and @InputNumber()

5411 of 6635 branches covered (81.55%)

Branch coverage included in aggregate %.

217 of 223 new or added lines in 82 files covered. (97.31%)

201 existing lines in 53 files now uncovered.

13176 of 13925 relevant lines covered (94.62%)

980.1 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
    DOWN_ARROW,
28
    elementMatchClosest,
29
    END,
1✔
30
    ENTER,
31
    FunctionProp,
78✔
32
    hasModifierKey,
78✔
33
    helpers,
78✔
34
    HOME,
78✔
35
    isArray,
36
    isFunction,
37
    LEFT_ARROW,
1✔
38
    RIGHT_ARROW,
39
    SPACE,
40
    UP_ARROW
444✔
41
} from 'ngx-tethys/util';
42
import { defer, merge, Observable, Subject, Subscription, timer } from 'rxjs';
43
import { distinctUntilChanged, filter, map, startWith, switchMap, take, takeUntil } from 'rxjs/operators';
113✔
44

45
import { ActiveDescendantKeyManager } from '@angular/cdk/a11y';
46
import { coerceElement } from '@angular/cdk/coercion';
1✔
47
import { SelectionModel } from '@angular/cdk/collections';
48
import { CdkConnectedOverlay, CdkOverlayOrigin, ConnectionPositionPair, Overlay, ScrollStrategy } from '@angular/cdk/overlay';
49
import { isPlatformBrowser, NgClass, NgFor, NgIf, NgTemplateOutlet } from '@angular/common';
2✔
50
import {
51
    AfterContentInit,
52
    AfterViewInit,
113✔
53
    booleanAttribute,
54
    ChangeDetectionStrategy,
55
    ChangeDetectorRef,
882✔
56
    Component,
57
    ContentChild,
58
    ContentChildren,
4!
UNCOV
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,
348✔
75
    ViewChild,
348✔
76
    ViewChildren
348✔
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
171!
86
} from '../select.config';
171✔
87

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

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

92
export const SELECT_PANEL_MAX_HEIGHT = 300;
93

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

96
export const SELECT_OPTION_GROUP_MAX_HEIGHT = 30;
171✔
97

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

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

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

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

171✔
118
interface ThyOptionGroupModel extends ThySelectOptionModel {
171✔
119
    children?: ThySelectOptionModel[];
178!
120
}
872✔
121

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

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

1✔
175
    size: SelectControlSize;
1✔
176

3✔
177
    mode: SelectMode = '';
1✔
178

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

181
    emptySearchMessageText = '暂无可选项';
1✔
182

183
    scrollTop = 0;
1✔
184

3✔
185
    modalValue: any = null;
2✔
186

2✔
187
    defaultOffset = 4;
1✔
188

1✔
189
    dropDownClass: { [key: string]: boolean };
190

191
    dropDownMinWidth: number | null = null;
192

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

12✔
199
    public dropDownPositions: ConnectionPositionPair[];
1✔
200

201
    public selectionModel: SelectionModel<ThyOption>;
202

3✔
203
    public triggerRectWidth: number;
204

205
    public scrollStrategy: ScrollStrategy;
206

187✔
207
    private resizeSubscription: Subscription;
187✔
208

187✔
209
    private selectionModelSubscription: Subscription;
1✔
210

211
    /**
186✔
212
     * 手动聚焦中的标识
4✔
213
     */
214
    private manualFocusing = false;
215

182✔
216
    private config: ThySelectConfig;
217

187✔
218
    private readonly destroy$ = new Subject<void>();
219

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

230
    @ViewChild(CdkConnectedOverlay, { static: true }) cdkConnectedOverlay: CdkConnectedOverlay;
170✔
231

178✔
232
    @HostBinding('class.thy-select-custom') isSelectCustom = true;
178✔
233

178✔
234
    @HostBinding('class.thy-select') isSelect = true;
178✔
235

1✔
236
    keyManager: ActiveDescendantKeyManager<ThyOption>;
1✔
237

238
    @HostBinding('class.menu-is-opened')
178✔
239
    panelOpen = false;
178✔
240

241
    /**
242
     * 搜索时回调
243
     */
178✔
244
    @Output() thyOnSearch: EventEmitter<string> = new EventEmitter<string>();
41✔
245

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

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

256
    /**
257
     * 下拉列表是否显示搜索框
247✔
258
     * @default false
259
     */
260
    @Input({ transform: booleanAttribute }) thyShowSearch: boolean;
79✔
261

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

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

273
    /**
274
     * 异步加载 loading 状态,false 表示加载中,true 表示加载完成
275
     */
276
    @Input({ transform: booleanAttribute }) thyLoadState = true;
1!
277

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

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

2✔
295
    get thyMode(): SelectMode {
296
        return this.mode;
297
    }
9✔
298

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

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

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

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

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

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

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

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

363
    /**
364
     * 弹出位置
365
     * @type ThyPlacement
366
     */
446✔
367
    @Input()
368
    thyPlacement: ThyPlacement;
369

1,920✔
370
    /**
371
     * 自定义 Overlay Origin
372
     */
79✔
373
    @Input()
374
    thyOrigin: ElementRef | HTMLElement;
375

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

8✔
382
    /**
383
     * @private
384
     */
385
    @ContentChild('selectedDisplay') selectedValueDisplayRef: TemplateRef<any>;
79✔
386

387
    /**
388
     * 初始化时,是否展开面板
389
     * @default false
82✔
390
     */
2✔
391
    @Input({ transform: booleanAttribute }) thyAutoExpand: boolean;
392

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

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

29✔
404
    /**
29✔
405
     * 是否隐藏选择框边框
29✔
406
     * @default false
407
     */
408
    @Input({ transform: booleanAttribute }) thyBorderless = false;
409

47✔
410
    isReactiveDriven = false;
47✔
411

66✔
412
    innerOptions: ThySelectOptionModel[];
413

47✔
414
    optionGroups: ThyOptionGroupModel[] = [];
25✔
415

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

90✔
430
    options: QueryList<ThyOption>;
80✔
431

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

73✔
438
    @ViewChild('trigger', { read: ElementRef, static: true }) trigger: ElementRef<HTMLElement>;
439

440
    @ViewChild('panel', { read: ElementRef }) panel: ElementRef<HTMLElement>;
1✔
441

442
    /**
443
     * @private
444
     */
10!
UNCOV
445
    @ContentChildren(ThyOption, { descendants: true }) contentOptions: QueryList<ThyOption>;
×
446

447
    @ViewChildren(ThyOption) viewOptions: QueryList<ThyOption>;
448

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

178✔
454
    @ViewChildren(ThySelectOptionGroup) viewGroups: QueryList<ThySelectOptionGroup>;
2✔
455

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

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

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

2✔
489
    private isSearching = false;
2✔
490

491
    groupBy = (item: ThySelectOptionModel) => item.groupLabel;
492

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

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

3!
UNCOV
512
    writeValue(value: any): void {
×
UNCOV
513
        this.modalValue = value;
×
514
        this.setSelectionByModelValue(this.modalValue);
515
    }
516

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

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

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

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

574
    getDropdownMinWidth(): number | null {
575
        const mode = this.thyDropdownWidthMode || this.config.dropdownWidthMode;
265✔
576
        let dropdownMinWidth: number | null = null;
265✔
577

43✔
578
        if ((mode as { minWidth: number })?.minWidth) {
579
            dropdownMinWidth = (mode as { minWidth: number }).minWidth;
580
        } else if (mode === 'min-width') {
222✔
581
            dropdownMinWidth = THY_SELECT_PANEL_MIN_WIDTH;
582
        } else {
265✔
583
            dropdownMinWidth = null;
584
        }
585

586
        return dropdownMinWidth;
587
    }
588

423✔
589
    ngAfterViewInit(): void {
235✔
590
        if (this.isReactiveDriven) {
1✔
591
            this.setup();
1✔
592
        }
593
    }
235✔
594

595
    ngAfterContentInit() {
188✔
596
        if (!this.isReactiveDriven) {
28!
597
            this.setup();
28✔
598
        }
28✔
599
    }
28✔
600

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

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

33✔
630
    public get isHiddenOptions(): boolean {
631
        return this.options.toArray().every(option => option.hidden);
104✔
632
    }
56✔
633

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

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

656
    public onOptionsScrolled(elementRef: ElementRef) {
657
        const scroll = elementRef.nativeElement.scrollTop,
80✔
658
            height = elementRef.nativeElement.clientHeight,
659
            scrollHeight = elementRef.nativeElement.scrollHeight;
660

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

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

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

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

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

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

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

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

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

755
    public get isMultiple(): boolean {
155✔
756
        return this.mode === 'multiple';
757
    }
758

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

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

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

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

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

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

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

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

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

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

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

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

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

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

922
            manager.onKeydown(event);
923

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

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

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

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

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

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

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

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

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

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

1029
            if (isUserInput) {
1030
                this.keyManager.setActiveItem(option);
1031
            }
1032

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

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

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

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

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

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

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

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