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

atinc / ngx-tethys / 68ef226c-f83e-44c1-b8ed-e420a83c5d84

28 May 2025 10:31AM UTC coverage: 10.352% (-80.0%) from 90.316%
68ef226c-f83e-44c1-b8ed-e420a83c5d84

Pull #3460

circleci

pubuzhixing8
chore: xxx
Pull Request #3460: refactor(icon): migrate signal input #TINFR-1476

132 of 6823 branches covered (1.93%)

Branch coverage included in aggregate %.

10 of 14 new or added lines in 1 file covered. (71.43%)

11648 existing lines in 344 files now uncovered.

2078 of 14525 relevant lines covered (14.31%)

6.69 hits per line

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

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

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

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

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

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

UNCOV
94
export const SELECT_PANEL_MAX_HEIGHT = 300;
×
95

96
export const SELECT_OPTION_MAX_HEIGHT = 40;
UNCOV
97

×
UNCOV
98
export const SELECT_OPTION_GROUP_MAX_HEIGHT = 30;
×
UNCOV
99

×
UNCOV
100
export const SELECT_PANEL_PADDING_TOP = 10;
×
UNCOV
101

×
UNCOV
102
export const THY_SELECT_PANEL_MIN_WIDTH = 200;
×
UNCOV
103

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

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

×
UNCOV
120
interface ThyOptionGroupModel extends ThySelectOptionModel {
×
UNCOV
121
    children?: ThySelectOptionModel[];
×
UNCOV
122
}
×
UNCOV
123

×
124
const noop = () => {};
125

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

×
183
    disabled = false;
184

185
    size: SelectControlSize;
UNCOV
186

×
187
    mode: SelectMode = '';
UNCOV
188

×
UNCOV
189
    emptyStateText = this.locale().empty;
×
UNCOV
190

×
UNCOV
191
    emptySearchMessageText = this.locale().empty;
×
UNCOV
192

×
UNCOV
193
    scrollTop = 0;
×
194

195
    modalValue: any = null;
196

UNCOV
197
    defaultOffset = 4;
×
198

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

×
201
    dropDownMinWidth: number | null = null;
202

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

209
    public dropDownPositions: ConnectionPositionPair[];
210

UNCOV
211
    public selectionModel: SelectionModel<ThyOption>;
×
UNCOV
212

×
UNCOV
213
    public triggerRectWidth: number;
×
UNCOV
214

×
215
    public scrollStrategy: ScrollStrategy;
UNCOV
216

×
UNCOV
217
    private resizeSubscription: Subscription;
×
218

219
    private selectionModelSubscription: Subscription;
UNCOV
220

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

×
UNCOV
226
    private config: ThySelectConfig;
×
227

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

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

×
UNCOV
240
    @ViewChild(CdkConnectedOverlay, { static: true }) cdkConnectedOverlay: CdkConnectedOverlay;
×
UNCOV
241

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

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

246
    keyManager: ActiveDescendantKeyManager<ThyOption>;
247

UNCOV
248
    @HostBinding('class.menu-is-opened')
×
UNCOV
249
    panelOpen = false;
×
250

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

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

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

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

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

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

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

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

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

×
UNCOV
305
    get thyMode(): SelectMode {
×
306
        return this.mode;
307
    }
UNCOV
308

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

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

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

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

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

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

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

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

373
    /**
UNCOV
374
     * 弹出位置
×
375
     * @type ThyPlacement
376
     */
UNCOV
377
    @Input()
×
378
    thyPlacement: ThyPlacement;
379

UNCOV
380
    /**
×
UNCOV
381
     * 自定义 Overlay Origin
×
382
     */
383
    @Input()
UNCOV
384
    thyOrigin: ElementRef | HTMLElement;
×
UNCOV
385

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

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

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

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

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

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

×
420
    isReactiveDriven = false;
421

UNCOV
422
    innerOptions: ThySelectOptionModel[];
×
UNCOV
423

×
424
    optionGroups: ThyOptionGroupModel[] = [];
425

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

×
UNCOV
440
    options: QueryList<ThyOption>;
×
441

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

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

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

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

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

×
UNCOV
459
    /**
×
460
     * @private
UNCOV
461
     */
×
462
    @ContentChildren(ThySelectOptionGroup) contentGroups: QueryList<ThySelectOptionGroup>;
463

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

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

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

UNCOV
491
    private buildScrollStrategy() {
×
UNCOV
492
        if (this.scrollStrategyFactory && isFunction(this.scrollStrategyFactory)) {
×
UNCOV
493
            this.scrollStrategy = this.scrollStrategyFactory();
×
UNCOV
494
        } else {
×
495
            this.scrollStrategy = this.overlay.scrollStrategies.reposition();
496
        }
UNCOV
497
    }
×
498

499
    private isSearching = false;
500

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

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

×
UNCOV
507
    constructor() {
×
508
        super();
UNCOV
509
        const selectConfig = this.selectConfig;
×
510

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

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

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

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

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

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

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

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

589
        return dropdownMinWidth;
590
    }
591

592
    ngAfterViewInit(): void {
UNCOV
593
        if (this.isReactiveDriven) {
×
UNCOV
594
            this.setup();
×
UNCOV
595
        }
×
UNCOV
596
    }
×
597

UNCOV
598
    ngAfterContentInit() {
×
599
        if (!this.isReactiveDriven) {
UNCOV
600
            this.setup();
×
UNCOV
601
        }
×
UNCOV
602
    }
×
UNCOV
603

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

925
            manager.onKeydown(event);
926

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

© 2025 Coveralls, Inc