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

atinc / ngx-tethys / 885137ef-f6d6-48d5-9a75-dc9ea93c7eee

13 Dec 2023 03:09AM UTC coverage: 90.396%. Remained the same
885137ef-f6d6-48d5-9a75-dc9ea93c7eee

push

circleci

web-flow
fix(select): remove focus() when close panel #INFR-10914 (#2968)

5351 of 6580 branches covered (0.0%)

Branch coverage included in aggregate %.

1 of 2 new or added lines in 1 file covered. (50.0%)

17 existing lines in 1 file now uncovered.

13332 of 14088 relevant lines covered (94.63%)

972.97 hits per line

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

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

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

168!
87
import {
168✔
88
    DEFAULT_SELECT_CONFIG,
89
    THY_SELECT_CONFIG,
NEW
90
    THY_SELECT_SCROLL_STRATEGY,
×
91
    ThyDropdownWidthMode,
92
    ThySelectConfig
93
} from '../select.config';
94

1,490✔
95
export type SelectMode = 'multiple' | '';
96

97
export type ThyCustomSelectTriggerType = 'click' | 'hover';
168✔
98

168✔
99
export const SELECT_PANEL_MAX_HEIGHT = 300;
168✔
100

168✔
101
export const SELECT_OPTION_MAX_HEIGHT = 40;
168✔
102

168✔
103
export const SELECT_OPTION_GROUP_MAX_HEIGHT = 30;
168✔
104

168✔
105
export const SELECT_PANEL_PADDING_TOP = 10;
168✔
106

168✔
107
export const THY_SELECT_PANEL_MIN_WIDTH = 200;
168✔
108

168✔
109
export interface OptionValue {
168✔
110
    thyLabelText?: string;
168✔
111
    thyValue?: string;
168✔
112
    thyDisabled?: boolean;
168✔
113
    thyShowOptionCustom?: boolean;
168✔
114
    thySearchKey?: string;
168✔
115
}
116

117
export interface ThySelectOptionModel {
118
    value?: string | number;
168✔
119
    disabled?: boolean;
168✔
120
    label?: string;
168✔
121
    icon?: string;
175!
122
    groupLabel?: string;
861✔
123
}
UNCOV
124

×
125
interface ThyOptionGroupModel extends ThySelectOptionModel {
126
    children?: ThySelectOptionModel[];
168✔
127
}
168✔
128

168✔
129
const noop = () => {};
168✔
130

168✔
131
/**
168✔
132
 * 下拉选择组件
168✔
133
 * @name thy-custom-select
168✔
134
 * @order 10
168✔
135
 */
168✔
136
@Component({
168✔
137
    selector: 'thy-custom-select',
168✔
138
    templateUrl: './custom-select.component.html',
168✔
139
    exportAs: 'thyCustomSelect',
168✔
140
    providers: [
168✔
141
        {
168✔
142
            provide: THY_OPTION_PARENT_COMPONENT,
168✔
143
            useExisting: ThySelectCustomComponent
168✔
144
        },
168✔
145
        {
168✔
146
            provide: NG_VALUE_ACCESSOR,
168✔
147
            useExisting: forwardRef(() => ThySelectCustomComponent),
148
            multi: true
149
        }
239✔
150
    ],
239✔
151
    changeDetection: ChangeDetectionStrategy.OnPush,
152
    standalone: true,
153
    imports: [
184✔
154
        CdkOverlayOrigin,
184✔
155
        ThySelectControlComponent,
184✔
156
        CdkConnectedOverlay,
157
        ThyStopPropagationDirective,
158
        NgClass,
159
        NgIf,
1!
160
        ThyScrollDirective,
1✔
161
        ThyLoadingComponent,
1✔
162
        ThyEmptyComponent,
163
        ThyOptionsContainerComponent,
164
        ThyOptionComponent,
184✔
165
        ThySelectOptionGroupComponent,
98✔
166
        NgTemplateOutlet,
167
        NgFor
184✔
168
    ],
184!
169
    host: {
184✔
170
        '[attr.tabindex]': 'tabIndex',
171
        '(focus)': 'onFocus($event)',
172
        '(blur)': 'onBlur($event)'
173
    },
192✔
174
    animations: [scaleXMotion, scaleYMotion, scaleMotion]
4✔
175
})
4✔
176
export class ThySelectCustomComponent
4✔
177
    extends TabIndexDisabledControlValueAccessorMixin
178
    implements ControlValueAccessor, IThyOptionParentComponent, OnInit, AfterViewInit, AfterContentInit, OnDestroy
179
{
180
    disabled = false;
181

182
    size: SelectControlSize;
183

1✔
184
    mode: SelectMode = '';
3✔
185

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

3✔
188
    emptySearchMessageText = '暂无可选项';
1✔
189

190
    scrollTop = 0;
191

192
    modalValue: any = null;
1✔
193

194
    defaultOffset = 4;
1✔
195

3✔
196
    dropDownClass: { [key: string]: boolean };
2✔
197

2✔
198
    dropDownMinWidth: number | null = null;
1✔
199

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

206
    public dropDownPositions: ConnectionPositionPair[];
1✔
207

208
    public selectionModel: SelectionModel<ThyOptionComponent>;
209

12✔
210
    public triggerRectWidth: number;
1✔
211

212
    public scrollStrategy: ScrollStrategy;
213

3✔
214
    private selectionModelSubscription: Subscription;
215

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

1✔
221
    private config: ThySelectConfig;
222

183✔
223
    private readonly destroy$ = new Subject<void>();
4✔
224

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

235
    @ViewChild(CdkConnectedOverlay, { static: true }) cdkConnectedOverlay: CdkConnectedOverlay;
236

167✔
237
    @HostBinding('class.thy-select-custom') isSelectCustom = true;
164✔
238

239
    @HostBinding('class.thy-select') isSelect = true;
240

241
    keyManager: ActiveDescendantKeyManager<ThyOptionComponent>;
167✔
242

175✔
243
    @HostBinding('class.menu-is-opened')
175✔
244
    panelOpen = false;
175✔
245

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

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

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

1✔
261
    /**
1✔
262
     * 下拉列表是否显示搜索框
1✔
263
     * @default false
1✔
264
     */
265
    @Input() @InputBoolean() thyShowSearch: boolean;
266

267
    /**
268
     * 选择框默认文字
244✔
269
     */
270
    @Input() thyPlaceHolder: string;
271

78✔
272
    /**
73✔
273
     * 是否使用服务端搜索,当为 true 时,将不再在前端进行过滤
63✔
274
     * @default false
61✔
275
     */
61✔
276
    @Input() @InputBoolean() thyServerSearch: boolean;
277

278
    /**
2!
UNCOV
279
     * 异步加载 loading 状态,false 表示加载中,true 表示加载完成
×
UNCOV
280
     */
×
281
    @Input() @InputBoolean() thyLoadState = true;
282

283
    /**
284
     * 是否自动设置选项第一条为高亮状态
285
     */
286
    @Input() @InputBoolean() thyAutoActiveFirstItem = true;
287

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

300
    get thyMode(): SelectMode {
301
        return this.mode;
302
    }
11✔
303

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

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

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

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

340
    /**
28✔
341
     * 单选( thyMode="" 或者不设置)时,选择框支持清除
28✔
342
     */
28✔
343
    @Input() @InputBoolean() thyAllowClear = false;
344

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

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

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

370
    /**
49✔
371
     * 弹出位置
49✔
372
     * @type ThyPlacement
36✔
373
     */
374
    @Input()
375
    thyPlacement: ThyPlacement;
376

377
    /**
443✔
378
     * 自定义 Overlay Origin
379
     */
380
    @Input()
1,871✔
381
    thyOrigin: ElementRef | HTMLElement;
382

383
    /**
78✔
384
     * 自定义 Footer 模板容器 class
385
     */
386
    @Input()
1!
387
    thyFooterClass = 'thy-custom-select-footer';
1✔
388

389
    /**
390
     * @private
87✔
391
     */
9✔
392
    @ContentChild('selectedDisplay') selectedValueDisplayRef: TemplateRef<any>;
8✔
393

394
    /**
395
     * 初始化时,是否展开面板
396
     * @default false
78✔
397
     */
398
    @Input() @InputBoolean() thyAutoExpand: boolean;
399

400
    /**
81✔
401
     * 是否弹出透明遮罩,如果显示遮罩则会阻止滚动区域滚动
2✔
402
     */
403
    @Input()
79✔
404
    @InputBoolean()
79✔
405
    thyHasBackdrop = false;
79✔
406

79✔
407
    /**
408
     * 设置多选时最大显示的标签数量,0 表示不限制
409
     */
55✔
410
    @Input() @InputNumber() thyMaxTagCount = 0;
29✔
411

29✔
412
    /**
29✔
413
     * 是否隐藏选择框边框
29✔
414
     * @default false
415
     */
416
    @Input() @InputBoolean() thyBorderless = false;
417

40✔
418
    isReactiveDriven = false;
40✔
419

38✔
420
    innerOptions: ThySelectOptionModel[];
421

40✔
422
    optionGroups: ThyOptionGroupModel[] = [];
18✔
423

424
    /**
425
     * option 列表
22✔
426
     * @type ThySelectOptionModel[]
2✔
427
     */
428
    @Input()
429
    set thyOptions(value: ThySelectOptionModel[]) {
20✔
430
        if (value === null) {
431
            value = [];
432
        }
40✔
433
        this.innerOptions = value;
40✔
434
        this.isReactiveDriven = true;
435
        this.buildReactiveOptions();
79✔
436
    }
89!
437

89✔
438
    options: QueryList<ThyOptionComponent>;
79✔
439

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

72✔
446
    @ViewChild('trigger', { read: ElementRef, static: true }) trigger: ElementRef<HTMLElement>;
447

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

450
    /**
451
     * @private
452
     */
10!
UNCOV
453
    @ContentChildren(ThyOptionComponent, { descendants: true }) contentOptions: QueryList<ThyOptionComponent>;
×
454

455
    @ViewChildren(ThyOptionComponent) viewOptions: QueryList<ThyOptionComponent>;
456

10✔
457
    /**
458
     * @private
459
     */
460
    @ContentChildren(ThySelectOptionGroupComponent) contentGroups: QueryList<ThySelectOptionGroupComponent>;
461

175✔
462
    @ViewChildren(ThySelectOptionGroupComponent) viewGroups: QueryList<ThySelectOptionGroupComponent>;
2✔
463

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

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

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

2✔
497
    private isSearching = false;
2✔
498

499
    groupBy = (item: ThySelectOptionModel) => item.groupLabel;
500

1✔
501
    get placement(): ThyPlacement {
502
        return this.thyPlacement || this.config.placement;
503
    }
504

505
    constructor(
12✔
506
        private ngZone: NgZone,
12✔
507
        private elementRef: ElementRef,
12✔
508
        private viewportRuler: ViewportRuler,
12✔
509
        private changeDetectorRef: ChangeDetectorRef,
2✔
510
        private overlay: Overlay,
2✔
511
        private thyClickDispatcher: ThyClickDispatcher,
512
        @Inject(PLATFORM_ID) private platformId: string,
10✔
513
        @Optional() @Inject(THY_SELECT_SCROLL_STRATEGY) public scrollStrategyFactory: FunctionProp<ScrollStrategy>,
514
        @Optional() @Inject(THY_SELECT_CONFIG) public selectConfig: ThySelectConfig
1✔
515
    ) {
1✔
516
        super();
517
        this.config = { ...DEFAULT_SELECT_CONFIG, ...selectConfig };
9✔
518
        this.buildScrollStrategy();
3✔
519
    }
3!
UNCOV
520

×
UNCOV
521
    writeValue(value: any): void {
×
522
        this.modalValue = value;
523
        this.setSelectionByModelValue(this.modalValue);
524
    }
3✔
525

526
    ngOnInit() {
6!
UNCOV
527
        this.getPositions();
×
UNCOV
528
        this.dropDownMinWidth = this.getDropdownMinWidth();
×
UNCOV
529
        this.viewportRuler
×
UNCOV
530
            .change()
×
531
            .pipe(takeUntil(this.destroy$))
×
532
            .subscribe(() => {
533
                if (this.panelOpen) {
534
                    this.triggerRectWidth = this.getOriginRectWidth();
535
                    this.changeDetectorRef.markForCheck();
536
                }
6!
UNCOV
537
            });
×
538
        if (!this.selectionModel) {
539
            this.instanceSelectionModel();
6✔
540
        }
6✔
541
        this.setDropDownClass();
6!
542

543
        if (isPlatformBrowser(this.platformId)) {
544
            this.thyClickDispatcher
545
                .clicked(0)
UNCOV
546
                .pipe(takeUntil(this.destroy$))
×
547
                .subscribe(event => {
548
                    if (!this.elementRef.nativeElement.contains(event.target) && this.panelOpen) {
549
                        this.ngZone.run(() => {
550
                            this.close();
551
                            this.changeDetectorRef.markForCheck();
260✔
552
                        });
553
                    }
554
                });
174✔
555
        }
7✔
556
    }
557

174✔
558
    buildOptionGroups(options: ThySelectOptionModel[]) {
174✔
559
        const optionGroups: ThyOptionGroupModel[] = [];
7✔
560
        const groups = [...new Set(options.filter(item => this.groupBy(item)).map(sub => this.groupBy(sub)))];
7✔
561
        const groupMap = new Map();
562
        groups.forEach(group => {
174✔
563
            const children = options.filter(item => this.groupBy(item) === group);
93✔
564
            const groupOption = {
93✔
565
                groupLabel: group,
566
                children: children
567
            };
568
            groupMap.set(group, groupOption);
175✔
569
        });
175✔
570
        options.forEach(option => {
96✔
571
            if (this.groupBy(option)) {
96✔
572
                const currentIndex = optionGroups.findIndex(item => item.groupLabel === this.groupBy(option));
14✔
573
                if (currentIndex === -1) {
14✔
574
                    const item = groupMap.get(this.groupBy(option));
575
                    optionGroups.push(item);
576
                }
577
            } else {
578
                optionGroups.push(option);
175✔
579
            }
175✔
580
        });
581
        return optionGroups;
582
    }
583

260✔
584
    buildReactiveOptions() {
260✔
585
        if (this.innerOptions.filter(item => this.groupBy(item)).length > 0) {
39✔
586
            this.optionGroups = this.buildOptionGroups(this.innerOptions);
587
        } else {
588
            this.optionGroups = this.innerOptions;
221✔
589
        }
590
    }
260✔
591

592
    getDropdownMinWidth(): number | null {
593
        const mode = this.thyDropdownWidthMode || this.config.dropdownWidthMode;
594
        let dropdownMinWidth: number | null = null;
595

596
        if ((mode as { minWidth: number })?.minWidth) {
414✔
597
            dropdownMinWidth = (mode as { minWidth: number }).minWidth;
232✔
598
        } else if (mode === 'min-width') {
1✔
599
            dropdownMinWidth = THY_SELECT_PANEL_MIN_WIDTH;
1✔
600
        } else {
601
            dropdownMinWidth = null;
232✔
602
        }
603

182✔
604
        return dropdownMinWidth;
24!
605
    }
24✔
606

24✔
607
    ngAfterViewInit(): void {
24✔
608
        if (this.isReactiveDriven) {
185✔
609
            this.setup();
2✔
610
        }
25✔
611
    }
22✔
612

613
    ngAfterContentInit() {
614
        if (!this.isReactiveDriven) {
615
            this.setup();
616
        }
617
    }
158✔
618

176✔
619
    setup() {
620
        this.optionsChanges$.pipe(startWith(null), takeUntil(this.destroy$)).subscribe(data => {
158✔
621
            this.resetOptions();
127✔
622
            this.initializeSelection();
623
            this.initKeyManager();
624
            if (this.isSearching) {
182✔
625
                this.highlightCorrectOption(false);
626
                this.isSearching = false;
627
            }
97✔
628
            this.changeDetectorRef.markForCheck();
97!
UNCOV
629
            this.ngZone.onStable
×
UNCOV
630
                .asObservable()
×
631
                .pipe(take(1))
632
                .subscribe(() => {
633
                    if (this.cdkConnectedOverlay && this.cdkConnectedOverlay.overlayRef) {
97✔
634
                        this.cdkConnectedOverlay.overlayRef.updatePosition();
37✔
635
                    }
636
                });
97✔
637
        });
33✔
638

639
        if (this.thyAutoExpand) {
97✔
640
            timer(0).subscribe(() => {
49✔
641
                this.changeDetectorRef.markForCheck();
49✔
642
                this.open();
12✔
643
                this.focus();
644
            });
645
        }
646
    }
97✔
647

37✔
648
    public get isHiddenOptions(): boolean {
649
        return this.options.toArray().every(option => option.hidden);
97✔
650
    }
48✔
651

652
    public onAttached(): void {
97✔
653
        this.cdkConnectedOverlay.positionChange.pipe(take(1)).subscribe(() => {
654
            if (this.panel) {
655
                if (this.keyManager.activeItem) {
49!
656
                    ScrollToService.scrollToElement(this.keyManager.activeItem.element.nativeElement, this.panel.nativeElement);
49✔
657
                    this.changeDetectorRef.detectChanges();
49✔
658
                } else {
2✔
659
                    if (!this.empty) {
1✔
660
                        ScrollToService.scrollToElement(this.selectionModel.selected[0].element.nativeElement, this.panel.nativeElement);
661
                        this.changeDetectorRef.detectChanges();
662
                    }
663
                }
664
            }
665
        });
80✔
666
    }
667

668
    public dropDownMouseMove(event: MouseEvent) {
168✔
669
        if (this.keyManager.activeItem) {
168✔
670
            this.keyManager.setActiveItem(-1);
671
        }
1✔
672
    }
673

674
    public onOptionsScrolled(elementRef: ElementRef) {
675
        const scroll = elementRef.nativeElement.scrollTop,
676
            height = elementRef.nativeElement.clientHeight,
677
            scrollHeight = elementRef.nativeElement.scrollHeight;
678

679
        if (scroll + height + 10 >= scrollHeight) {
680
            if (this.thyOnScrollToBottom.observers.length > 0) {
681
                this.ngZone.run(() => {
682
                    this.thyOnScrollToBottom.emit();
1✔
683
                });
684
            }
685
        }
686
    }
687

688
    public onSearchFilter(searchText: string) {
689
        searchText = searchText.trim();
690
        if (this.thyServerSearch) {
691
            this.isSearching = true;
692
            this.thyOnSearch.emit(searchText);
693
        } else {
694
            const options = this.options.toArray();
695
            options.forEach(option => {
696
                if (option.matchSearchText(searchText)) {
697
                    option.showOption();
698
                } else {
699
                    option.hideOption();
700
                }
701
            });
702
            this.highlightCorrectOption(false);
703
            this.updateCdkConnectedOverlayPositions();
704
        }
705
    }
706

707
    onBlur(event?: FocusEvent) {
708
        // Tab 聚焦后自动聚焦到 input 输入框,此分支下直接返回,无需触发 onTouchedFn
709
        if (elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-select-dropdown', 'thy-custom-select'])) {
710
            return;
711
        }
712
        this.onTouchedFn();
713
    }
714

715
    onFocus(event?: FocusEvent) {
716
        // thyShowSearch 与 panelOpen 均为 true 时,点击 thySelectControl 需要触发自动聚焦到 input 的逻辑
717
        // manualFocusing 如果是手动聚焦,不触发自动聚焦到 input 的逻辑
718
        if (
719
            (this.thyShowSearch && this.panelOpen) ||
720
            (!this.manualFocusing &&
721
                !elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-select-dropdown', 'thy-custom-select']))
722
        ) {
723
            const inputElement: HTMLInputElement = this.elementRef.nativeElement.querySelector('input');
724
            inputElement.focus();
1✔
725
        }
726
        this.manualFocusing = false;
727
    }
728

1✔
729
    public focus(options?: FocusOptions): void {
730
        this.manualFocusing = true;
731
        this.elementRef.nativeElement.focus(options);
732
        this.manualFocusing = false;
1✔
733
    }
734

735
    public remove($event: { item: ThyOptionComponent; $eventOrigin: Event }) {
736
        $event.$eventOrigin.stopPropagation();
1✔
737
        if (this.disabled) {
738
            return;
739
        }
740
        if (!this.options.find(option => option === $event.item)) {
1✔
741
            $event.item.deselect();
742
            // fix option unselect can not emit changes;
743
            this.onSelect($event.item, true);
744
        } else {
1✔
745
            $event.item.deselect();
746
        }
747
    }
748

1✔
749
    public clearSelectValue(event?: Event) {
750
        if (event) {
751
            event.stopPropagation();
752
        }
753
        if (this.disabled) {
1✔
754
            return;
755
        }
756
        this.selectionModel.clear();
757
        this.changeDetectorRef.markForCheck();
1✔
758
        this.emitModelValueChange();
759
    }
760

761
    public updateCdkConnectedOverlayPositions(): void {
1✔
762
        setTimeout(() => {
763
            if (this.cdkConnectedOverlay && this.cdkConnectedOverlay.overlayRef) {
764
                this.cdkConnectedOverlay.overlayRef.updatePosition();
765
            }
1✔
766
        });
767
    }
768

769
    public get selected(): ThyOptionComponent | ThyOptionComponent[] {
1✔
770
        return this.isMultiple ? this.selectionModel.selected : this.selectionModel.selected[0];
771
    }
772

773
    public get isMultiple(): boolean {
774
        return this.mode === 'multiple';
775
    }
776

777
    public get empty(): boolean {
778
        return !this.selectionModel || this.selectionModel.isEmpty();
779
    }
780

781
    public getItemCount(): number {
152✔
782
        const group = this.isReactiveDriven ? this.viewGroups : this.contentGroups;
783
        return this.options.length + group.length;
784
    }
785

786
    public toggle(event: MouseEvent): void {
787
        if (this.panelOpen) {
788
            if (!this.thyShowSearch) {
789
                this.close();
790
            }
791
        } else {
792
            this.open();
793
        }
794
    }
795

796
    public open(): void {
797
        if (this.disabled || !this.options || this.panelOpen) {
798
            return;
799
        }
800
        this.triggerRectWidth = this.getOriginRectWidth();
801
        this.panelOpen = true;
802
        this.highlightCorrectOption();
803
        this.thyOnExpandStatusChange.emit(this.panelOpen);
804
    }
805

806
    public close(): void {
807
        if (this.panelOpen) {
808
            this.panelOpen = false;
809
            this.thyOnExpandStatusChange.emit(this.panelOpen);
810
            this.changeDetectorRef.markForCheck();
811
            this.onTouchedFn();
812
        }
813
    }
814

815
    private emitModelValueChange() {
816
        const selectedValues = this.selectionModel.selected;
817
        const changeValue = selectedValues.map((option: ThyOptionComponent) => {
818
            return option.thyValue;
819
        });
820
        if (this.isMultiple) {
821
            this.modalValue = changeValue;
822
        } else {
823
            if (changeValue.length === 0) {
824
                this.modalValue = null;
825
            } else {
826
                this.modalValue = changeValue[0];
827
            }
828
        }
829
        this.onChangeFn(this.modalValue);
830
        this.updateCdkConnectedOverlayPositions();
831
    }
832

833
    private highlightCorrectOption(fromOpenPanel: boolean = true): void {
834
        if (this.keyManager && this.panelOpen) {
835
            if (fromOpenPanel) {
836
                if (this.keyManager.activeItem) {
837
                    return;
838
                }
839
                if (this.empty) {
840
                    if (!this.thyAutoActiveFirstItem) {
841
                        return;
842
                    }
843
                    this.keyManager.setFirstItemActive();
844
                } else {
845
                    this.keyManager.setActiveItem(this.selectionModel.selected[0]);
846
                }
847
            } else {
848
                if (!this.thyAutoActiveFirstItem) {
849
                    return;
850
                }
851
                // always set first option active
852
                this.keyManager.setFirstItemActive();
853
            }
854
        }
855
    }
856

857
    private initKeyManager() {
858
        if (this.keyManager && this.keyManager.activeItem) {
859
            this.keyManager.activeItem.setInactiveStyles();
860
        }
861
        this.keyManager = new ActiveDescendantKeyManager<ThyOptionComponent>(this.options)
862
            .withTypeAhead()
863
            .withWrap()
864
            .withVerticalOrientation()
865
            .withAllowedModifierKeys(['shiftKey']);
866

867
        this.keyManager.tabOut.pipe(takeUntil(this.destroy$)).subscribe(() => {
868
            this.focus();
869
            this.close();
870
        });
871
        this.keyManager.change.pipe(takeUntil(this.destroy$)).subscribe(() => {
872
            if (this.panelOpen && this.panel) {
873
                if (this.keyManager.activeItem) {
874
                    ScrollToService.scrollToElement(this.keyManager.activeItem.element.nativeElement, this.panel.nativeElement);
875
                }
876
            } else if (!this.panelOpen && !this.isMultiple && this.keyManager.activeItem) {
877
                this.keyManager.activeItem.selectViaInteraction();
878
            }
879
        });
880
    }
881

882
    private handleClosedKeydown(event: KeyboardEvent): void {
883
        const keyCode = event.keyCode;
884
        const isArrowKey = keyCode === DOWN_ARROW || keyCode === UP_ARROW || keyCode === LEFT_ARROW || keyCode === RIGHT_ARROW;
885
        const isOpenKey = keyCode === ENTER || keyCode === SPACE;
886
        const manager = this.keyManager;
887

888
        // Open the select on ALT + arrow key to match the native <select>
889
        if ((isOpenKey && !hasModifierKey(event)) || ((this.isMultiple || event.altKey) && isArrowKey)) {
890
            event.preventDefault(); // prevents the page from scrolling down when pressing space
891
            this.open();
892
        } else if (!this.isMultiple) {
893
            if (keyCode === HOME || keyCode === END) {
894
                keyCode === HOME ? manager.setFirstItemActive() : manager.setLastItemActive();
895
                event.preventDefault();
896
            } else {
897
                manager.onKeydown(event);
898
            }
899
        }
900
    }
901

902
    private handleOpenKeydown(event: KeyboardEvent): void {
903
        const keyCode = event.keyCode;
904
        const isArrowKey = keyCode === DOWN_ARROW || keyCode === UP_ARROW;
905
        const manager = this.keyManager;
906

907
        if (keyCode === HOME || keyCode === END) {
908
            event.preventDefault();
909
            keyCode === HOME ? manager.setFirstItemActive() : manager.setLastItemActive();
910
        } else if (isArrowKey && event.altKey) {
911
            // Close the select on ALT + arrow key to match the native <select>
912
            event.preventDefault();
913
            this.close();
914
        } else if ((keyCode === ENTER || keyCode === SPACE) && (manager.activeItem || !this.empty) && !hasModifierKey(event)) {
915
            event.preventDefault();
916
            if (!manager.activeItem) {
917
                if (manager.activeItemIndex === -1 && !this.empty) {
918
                    manager.setActiveItem(this.selectionModel.selected[0]);
919
                }
920
            }
921
            manager.activeItem.selectViaInteraction();
922
        } else if (this.isMultiple && keyCode === A && event.ctrlKey) {
923
            event.preventDefault();
924
            const hasDeselectedOptions = this.options.some(opt => !opt.disabled && !opt.selected);
925

926
            this.options.forEach(option => {
927
                if (!option.disabled) {
928
                    hasDeselectedOptions ? option.select() : option.deselect();
929
                }
930
            });
931
        } else {
932
            if (manager.activeItemIndex === -1 && !this.empty) {
933
                manager.setActiveItem(this.selectionModel.selected[0]);
934
            }
935
            const previouslyFocusedIndex = manager.activeItemIndex;
936

937
            manager.onKeydown(event);
938

939
            if (
940
                this.isMultiple &&
941
                isArrowKey &&
942
                event.shiftKey &&
943
                manager.activeItem &&
944
                manager.activeItemIndex !== previouslyFocusedIndex
945
            ) {
946
                manager.activeItem.selectViaInteraction();
947
            }
948
        }
949
    }
950

951
    private getPositions() {
952
        this.dropDownPositions = getFlexiblePositions(this.thyPlacement || this.config.placement, this.defaultOffset);
953
    }
954

955
    private instanceSelectionModel() {
956
        if (this.selectionModel) {
957
            this.selectionModel.clear();
958
        }
959
        this.selectionModel = new SelectionModel<ThyOptionComponent>(this.isMultiple);
960
        if (this.selectionModelSubscription) {
961
            this.selectionModelSubscription.unsubscribe();
962
            this.selectionModelSubscription = null;
963
        }
964
        this.selectionModelSubscription = this.selectionModel.changed.pipe(takeUntil(this.destroy$)).subscribe(event => {
965
            event.added.forEach(option => option.select());
966
            event.removed.forEach(option => option.deselect());
967
        });
968
    }
969

970
    private resetOptions() {
971
        const changedOrDestroyed$ = merge(this.optionsChanges$, this.destroy$);
972

973
        this.optionSelectionChanges.pipe(takeUntil(changedOrDestroyed$)).subscribe((event: ThyOptionSelectionChangeEvent) => {
974
            this.onSelect(event.option, event.isUserInput);
975
            if (event.isUserInput && !this.isMultiple && this.panelOpen) {
976
                this.close();
977
                this.focus();
978
            }
979
        });
980
    }
981

982
    private initializeSelection() {
983
        Promise.resolve().then(() => {
984
            this.setSelectionByModelValue(this.modalValue);
985
        });
986
    }
987

988
    private setDropDownClass() {
989
        let modeClass = '';
990
        if (this.isMultiple) {
991
            modeClass = `thy-select-dropdown-${this.mode}`;
992
        } else {
993
            modeClass = `thy-select-dropdown-single`;
994
        }
995
        this.dropDownClass = {
996
            [`thy-select-dropdown`]: true,
997
            [modeClass]: true
998
        };
999
    }
1000

1001
    private setSelectionByModelValue(modalValue: any) {
1002
        if (helpers.isUndefinedOrNull(modalValue)) {
1003
            if (this.selectionModel.selected.length > 0) {
1004
                this.selectionModel.clear();
1005
                this.changeDetectorRef.markForCheck();
1006
            }
1007
            return;
1008
        }
1009
        if (this.isMultiple) {
1010
            if (isArray(modalValue)) {
1011
                const selected = [...this.selectionModel.selected];
1012
                this.selectionModel.clear();
1013
                (modalValue as Array<any>).forEach(itemValue => {
1014
                    const option =
1015
                        this.options.find(_option => _option.thyValue === itemValue) ||
1016
                        selected.find(_option => _option.thyValue === itemValue);
1017
                    if (option) {
1018
                        this.selectionModel.select(option);
1019
                    }
1020
                });
1021
            }
1022
        } else {
1023
            const selectedOption = this.options?.find(option => {
1024
                return option.thyValue === modalValue;
1025
            });
1026
            if (selectedOption) {
1027
                this.selectionModel.select(selectedOption);
1028
            }
1029
        }
1030
        this.changeDetectorRef.markForCheck();
1031
    }
1032

1033
    private onSelect(option: ThyOptionComponent, isUserInput: boolean) {
1034
        const wasSelected = this.selectionModel.isSelected(option);
1035

1036
        if (option.thyValue == null && !this.isMultiple) {
1037
            option.deselect();
1038
            this.selectionModel.clear();
1039
        } else {
1040
            if (wasSelected !== option.selected) {
1041
                option.selected ? this.selectionModel.select(option) : this.selectionModel.deselect(option);
1042
            }
1043

1044
            if (isUserInput) {
1045
                this.keyManager.setActiveItem(option);
1046
            }
1047

1048
            if (this.isMultiple) {
1049
                this.sortValues();
1050
                if (isUserInput) {
1051
                    this.focus();
1052
                }
1053
            }
1054
        }
1055

1056
        if (wasSelected !== this.selectionModel.isSelected(option)) {
1057
            this.emitModelValueChange();
1058
        }
1059
        if (!this.isMultiple) {
1060
            this.onTouchedFn();
1061
        }
1062
        this.changeDetectorRef.markForCheck();
1063
    }
1064

1065
    private sortValues() {
1066
        if (this.isMultiple) {
1067
            const options = this.options.toArray();
1068

1069
            if (this.thySortComparator) {
1070
                this.selectionModel.sort((a, b) => {
1071
                    return this.thySortComparator(a, b, options);
1072
                });
1073
            }
1074
        }
1075
    }
1076

1077
    private getOriginRectWidth() {
1078
        return this.thyOrigin ? coerceElement(this.thyOrigin).offsetWidth : this.trigger.nativeElement.offsetWidth;
1079
    }
1080

1081
    ngOnDestroy() {
1082
        this.destroy$.next();
1083
        this.destroy$.complete();
1084
    }
1085
}
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