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

atinc / ngx-tethys / cd64db52-e563-41a3-85f3-a0adb87ce135

30 Oct 2024 08:03AM UTC coverage: 90.402% (-0.04%) from 90.438%
cd64db52-e563-41a3-85f3-a0adb87ce135

push

circleci

web-flow
refactor: refactor constructor to the inject function (#3222)

5503 of 6730 branches covered (81.77%)

Branch coverage included in aggregate %.

422 of 429 new or added lines in 170 files covered. (98.37%)

344 existing lines in 81 files now uncovered.

13184 of 13941 relevant lines covered (94.57%)

997.19 hits per line

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

92.16
/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
    Input,
65
    NgZone,
66
    numberAttribute,
17!
67
    OnDestroy,
17✔
68
    OnInit,
5✔
69
    Output,
70
    PLATFORM_ID,
17✔
71
    QueryList,
72
    TemplateRef,
73
    ViewChild,
74
    ViewChildren,
350✔
75
    inject
350✔
76
} from '@angular/core';
350✔
77
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
16✔
78

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

172✔
87
export type SelectMode = 'multiple' | '';
88

UNCOV
89
export type ThySelectTriggerType = 'click' | 'hover';
×
90

91
export const SELECT_PANEL_MAX_HEIGHT = 300;
92

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

95
export const SELECT_OPTION_GROUP_MAX_HEIGHT = 30;
96

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

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

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

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

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

876✔
121
const noop = () => {};
UNCOV
122

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

1✔
179
    disabled = false;
180

181
    size: SelectControlSize;
182

1✔
183
    mode: SelectMode = '';
184

1✔
185
    emptyStateText = '暂无可选项';
3✔
186

2✔
187
    emptySearchMessageText = '暂无可选项';
2✔
188

1✔
189
    scrollTop = 0;
1✔
190

191
    modalValue: any = null;
192

193
    defaultOffset = 4;
1✔
194

195
    dropDownClass: { [key: string]: boolean };
196

1✔
197
    dropDownMinWidth: number | null = null;
198

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

205
    public dropDownPositions: ConnectionPositionPair[];
206

207
    public selectionModel: SelectionModel<ThyOption>;
188✔
208

188✔
209
    public triggerRectWidth: number;
188✔
210

1✔
211
    public scrollStrategy: ScrollStrategy;
212

187✔
213
    private resizeSubscription: Subscription;
4✔
214

215
    private selectionModelSubscription: Subscription;
216

183✔
217
    /**
218
     * 手动聚焦中的标识
188✔
219
     */
220
    private manualFocusing = false;
221

171✔
222
    private config: ThySelectConfig;
3✔
223

224
    private readonly destroy$ = new Subject<void>();
225

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

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

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

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

242
    keyManager: ActiveDescendantKeyManager<ThyOption>;
243

244
    @HostBinding('class.menu-is-opened')
179✔
245
    panelOpen = false;
42✔
246

247
    /**
248
     * 搜索时回调
249
     */
171✔
250
    @Output() thyOnSearch: EventEmitter<string> = new EventEmitter<string>();
1✔
251

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

257
    /**
258
     * 下拉菜单展开和折叠状态事件
249✔
259
     */
260
    @Output() thyOnExpandStatusChange: EventEmitter<boolean> = new EventEmitter<boolean>();
261

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

268
    /**
2!
UNCOV
269
     * 选择框默认文字
×
UNCOV
270
     */
×
271
    @Input() thyPlaceHolder: string;
272

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

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

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

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

52✔
301
    get thyMode(): SelectMode {
12✔
302
        return this.mode;
303
    }
304

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

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

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

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

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

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

3✔
357
    /**
3✔
358
     * 排序比较函数
359
     */
360
    @Input() thySortComparator: (a: ThyOption, b: ThyOption, options: ThyOption[]) => number;
56✔
361

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

369
    /**
370
     * 弹出位置
1,932✔
371
     * @type ThyPlacement
372
     */
373
    @Input()
80✔
374
    thyPlacement: ThyPlacement;
375

376
    /**
1!
377
     * 自定义 Overlay Origin
1✔
378
     */
379
    @Input()
380
    thyOrigin: ElementRef | HTMLElement;
90✔
381

10✔
382
    /**
9✔
383
     * 自定义 Footer 模板容器 class
384
     */
385
    @Input()
386
    thyFooterClass = 'thy-custom-select-footer';
80✔
387

388
    /**
389
     * @private
390
     */
83✔
391
    @ContentChild('selectedDisplay') selectedValueDisplayRef: TemplateRef<any>;
2✔
392

393
    /**
81✔
394
     * 初始化时,是否展开面板
81✔
395
     * @default false
81✔
396
     */
81✔
397
    @Input({ transform: coerceBooleanProperty }) thyAutoExpand: boolean;
81✔
398

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

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

410
    /**
47✔
411
     * 是否隐藏选择框边框
47✔
412
     * @default false
66✔
413
     */
414
    @Input({ transform: coerceBooleanProperty }) thyBorderless = false;
47✔
415

25✔
416
    isReactiveDriven = false;
417

418
    innerOptions: ThySelectOptionModel[];
22✔
419

2✔
420
    optionGroups: ThyOptionGroupModel[] = [];
421

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

75✔
436
    options: QueryList<ThyOption>;
1✔
437

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

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

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

448
    /**
449
     * @private
10✔
450
     */
451
    @ContentChildren(ThyOption, { descendants: true }) contentOptions: QueryList<ThyOption>;
452

453
    @ViewChildren(ThyOption) viewOptions: QueryList<ThyOption>;
454

179✔
455
    /**
2✔
456
     * @private
457
     */
179✔
458
    @ContentChildren(ThySelectOptionGroup) contentGroups: QueryList<ThySelectOptionGroup>;
459

460
    @ViewChildren(ThySelectOptionGroup) viewGroups: QueryList<ThySelectOptionGroup>;
461

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

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

487
    private buildScrollStrategy() {
3!
488
        if (this.scrollStrategyFactory && isFunction(this.scrollStrategyFactory)) {
3✔
489
            this.scrollStrategy = this.scrollStrategyFactory();
2✔
490
        } else {
2✔
491
            this.scrollStrategy = this.overlay.scrollStrategies.reposition();
492
        }
493
    }
1✔
494

495
    private isSearching = false;
496

497
    groupBy = (item: ThySelectOptionModel) => item.groupLabel;
498

13✔
499
    get placement(): ThyPlacement {
13✔
500
        return this.thyPlacement || this.config.placement;
13✔
501
    }
13✔
502

2✔
503
    constructor() {
2✔
504
        super();
505
        const selectConfig = this.selectConfig;
11✔
506

507
        this.config = { ...DEFAULT_SELECT_CONFIG, ...selectConfig };
1✔
508
        this.buildScrollStrategy();
1✔
509
    }
510

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

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

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

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

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

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

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

585
        return dropdownMinWidth;
586
    }
587

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

921
            manager.onKeydown(event);
922

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

© 2026 Coveralls, Inc