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

atinc / ngx-tethys / f1829278-f975-408a-90b7-7f475fa8eac8

29 Nov 2023 02:04AM UTC coverage: 90.287% (-0.02%) from 90.303%
f1829278-f975-408a-90b7-7f475fa8eac8

Pull #2923

circleci

smile1016
fix(select): icon color
Pull Request #2923: feat(select):multi-select selected options support template #INFR-10631

5322 of 6556 branches covered (0.0%)

Branch coverage included in aggregate %.

13 of 14 new or added lines in 3 files covered. (92.86%)

62 existing lines in 3 files now uncovered.

13260 of 14025 relevant lines covered (94.55%)

975.78 hits per line

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

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

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

81
import {
×
82
    THY_SELECT_SCROLL_STRATEGY,
83
    THY_SELECT_CONFIG,
84
    ThySelectConfig,
85
    ThyDropdownWidthMode,
166✔
86
    DEFAULT_SELECT_CONFIG
166✔
87
} from '../select.config';
166✔
88

166✔
89
export type SelectMode = 'multiple' | '';
166✔
90

166✔
91
export type ThyCustomSelectTriggerType = 'click' | 'hover';
166✔
92

166✔
93
export const SELECT_PANEL_MAX_HEIGHT = 300;
166✔
94

166✔
95
export const SELECT_OPTION_MAX_HEIGHT = 40;
166✔
96

166✔
97
export const SELECT_OPTION_GROUP_MAX_HEIGHT = 30;
166✔
98

166✔
99
export const SELECT_PANEL_PADDING_TOP = 10;
166✔
100

166✔
101
export const THY_SELECT_PANEL_MIN_WIDTH = 200;
166✔
102

166✔
103
export interface OptionValue {
104
    thyLabelText?: string;
105
    thyValue?: string;
106
    thyDisabled?: boolean;
166✔
107
    thyShowOptionCustom?: boolean;
166✔
108
    thySearchKey?: string;
166✔
109
}
174!
110

861✔
111
const noop = () => {};
112

×
113
/**
114
 * 下拉选择组件
166✔
115
 * @name thy-custom-select
166✔
116
 * @order 10
166✔
117
 */
166✔
118
@Component({
166✔
119
    selector: 'thy-custom-select',
166✔
120
    templateUrl: './custom-select.component.html',
166✔
121
    exportAs: 'thyCustomSelect',
166✔
122
    providers: [
166✔
123
        {
166✔
124
            provide: THY_OPTION_PARENT_COMPONENT,
166✔
125
            useExisting: ThySelectCustomComponent
166✔
126
        },
166✔
127
        {
166✔
128
            provide: NG_VALUE_ACCESSOR,
166✔
129
            useExisting: forwardRef(() => ThySelectCustomComponent),
166✔
130
            multi: true
166✔
131
        }
132
    ],
133
    changeDetection: ChangeDetectionStrategy.OnPush,
233✔
134
    standalone: true,
233✔
135
    imports: [
136
        CdkOverlayOrigin,
137
        ThySelectControlComponent,
182✔
138
        CdkConnectedOverlay,
182✔
139
        ThyStopPropagationDirective,
182✔
140
        NgClass,
141
        NgIf,
142
        ThyScrollDirective,
143
        ThyLoadingComponent,
1!
144
        ThyEmptyComponent,
1✔
145
        NgTemplateOutlet
1✔
146
    ],
147
    host: {
148
        '[attr.tabindex]': 'tabIndex',
182✔
149
        '(focus)': 'onFocus($event)',
96✔
150
        '(blur)': 'onBlur($event)'
151
    }
182✔
152
})
182!
153
export class ThySelectCustomComponent
182✔
154
    extends TabIndexDisabledControlValueAccessorMixin
155
    implements ControlValueAccessor, IThyOptionParentComponent, OnInit, AfterContentInit, OnDestroy
156
{
157
    disabled = false;
192✔
158

5✔
159
    size: SelectControlSize;
5✔
160

5✔
161
    mode: SelectMode = '';
162

163
    emptyStateText = '暂无可选项';
164

165
    emptySearchMessageText = '暂无可选项';
166

167
    scrollTop = 0;
182✔
168

182✔
169
    modalValue: any = null;
182✔
170

1✔
171
    defaultOffset = 4;
172

181✔
173
    dropDownClass: { [key: string]: boolean };
4✔
174

175
    dropDownMinWidth: number | null = null;
176

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

174✔
183
    public dropDownPositions: ConnectionPositionPair[];
174✔
184

174✔
185
    public selectionModel: SelectionModel<ThyOptionComponent>;
174✔
186

1✔
187
    public triggerRectWidth: number;
1✔
188

189
    public scrollStrategy: ScrollStrategy;
174✔
190

174✔
191
    private selectionModelSubscription: Subscription;
192

193
    /**
194
     * 手动聚焦中的标识
174✔
195
     */
41✔
196
    private manualFocusing = false;
197

198
    private config: ThySelectConfig;
199

165✔
200
    private readonly destroy$ = new Subject<void>();
1✔
201

1✔
202
    readonly optionSelectionChanges: Observable<ThyOptionSelectionChangeEvent> = defer(() => {
1✔
203
        if (this.options) {
1✔
204
            return merge(...this.options.map(option => option.selectionChange));
205
        }
206
        return this.ngZone.onStable.asObservable().pipe(
207
            take(1),
208
            switchMap(() => this.optionSelectionChanges)
258✔
209
        );
210
    }) as Observable<ThyOptionSelectionChangeEvent>;
211

82✔
212
    @ViewChild(CdkConnectedOverlay, { static: true }) cdkConnectedOverlay: CdkConnectedOverlay;
77✔
213

66✔
214
    @HostBinding('class.thy-select-custom') isSelectCustom = true;
64✔
215

64✔
216
    @HostBinding('class.thy-select') isSelect = true;
217

218
    keyManager: ActiveDescendantKeyManager<ThyOptionComponent>;
2!
219

×
220
    @HostBinding('class.menu-is-opened')
×
221
    panelOpen = false;
222

223
    /**
224
     * 搜索时回调
225
     */
226
    @Output() thyOnSearch: EventEmitter<string> = new EventEmitter<string>();
227

1!
228
    /**
1✔
229
     * 下拉菜单滚动到底部事件,可以用这个事件实现滚动加载
230
     */
231
    @Output() thyOnScrollToBottom: EventEmitter<void> = new EventEmitter<void>();
232

1✔
233
    /**
1!
234
     * 下拉菜单展开和折叠状态事件
1!
235
     */
1✔
236
    @Output() thyOnExpandStatusChange: EventEmitter<boolean> = new EventEmitter<boolean>();
1✔
237

238
    /**
239
     * 下拉列表是否显示搜索框
240
     * @default false
241
     */
242
    @Input() @InputBoolean() thyShowSearch: boolean;
11✔
243

11✔
244
    /**
2✔
245
     * 选择框默认文字
2✔
246
     */
247
    @Input() thyPlaceHolder: string;
248

9✔
249
    /**
9✔
250
     * 是否使用服务端搜索,当为 true 时,将不再在前端进行过滤
52✔
251
     * @default false
12✔
252
     */
253
    @Input() @InputBoolean() thyServerSearch: boolean;
254

40✔
255
    /**
256
     * 异步加载 loading 状态,false 表示加载中,true 表示加载完成
257
     */
9✔
258
    @Input() @InputBoolean() thyLoadState = true;
9✔
259

260
    /**
261
     * 是否自动设置选项第一条为高亮状态
262
     */
263
    @Input() @InputBoolean() thyAutoActiveFirstItem = true;
2✔
264

1✔
265
    /**
266
     * 下拉选择模式
1✔
267
     * @type 'multiple' | ''
268
     */
269
    @Input()
270
    set thyMode(value: SelectMode) {
271
        this.mode = value;
31!
272
        this.instanceSelectionModel();
273
        this.getPositions();
274
        this.setDropDownClass();
3✔
275
    }
3✔
276

277
    get thyMode(): SelectMode {
31✔
278
        return this.mode;
279
    }
280

55✔
281
    /**
55✔
282
     * 操作图标类型
55✔
283
     * @type primary | success | danger | warning
284
     * @default primary
285
     */
4✔
286
    @Input()
4✔
287
    get thySize(): SelectControlSize {
1✔
288
        return this.size;
289
    }
18✔
290
    set thySize(value: SelectControlSize) {
1✔
291
        this.size = value;
292
    }
1✔
293

294
    /**
295
     * 数据为空时显示的提示文字
2✔
296
     */
297
    @Input()
298
    set thyEmptyStateText(value: string) {
299
        this.emptyStateText = value;
4✔
300
    }
3✔
301

302
    /**
4✔
303
     * 搜索结果为空时显示的提示文字
1✔
304
     */
305
    @Input()
3✔
306
    set thyEmptySearchMessageText(value: string) {
3✔
307
        this.emptySearchMessageText = value;
3✔
308
    }
309

310
    /**
49✔
311
     * 滚动加载是否可用,只能当这个参数可以,下面的thyOnScrollToBottom事件才会触发
49✔
312
     */
39✔
313
    @Input()
314
    @InputBoolean()
315
    thyEnableScrollLoad = false;
316

317
    /**
447✔
318
     * 单选( thyMode="" 或者不设置)时,选择框支持清除
319
     */
320
    @Input() @InputBoolean() thyAllowClear = false;
1,860✔
321

322
    /**
323
     * 是否禁用
82✔
324
     * @default false
325
     */
326
    @Input()
1✔
327
    @InputBoolean()
328
    get thyDisabled(): boolean {
329
        return this.disabled;
91✔
330
    }
9✔
331
    set thyDisabled(value: boolean) {
8✔
332
        this.disabled = coerceBooleanProperty(value);
333
    }
334

335
    /**
82✔
336
     * 排序比较函数
337
     */
338
    @Input() thySortComparator: (a: ThyOptionComponent, b: ThyOptionComponent, options: ThyOptionComponent[]) => number;
339

85✔
340
    /**
2✔
341
     * Footer 模板,默认值为空不显示 Footer
342
     * @type TemplateRef
83✔
343
     */
83✔
344
    @Input()
83✔
345
    thyFooterTemplate: TemplateRef<any>;
83✔
346

347
    /**
348
     * 弹出位置
55✔
349
     * @type ThyPlacement
29✔
350
     */
29✔
351
    @Input()
29✔
352
    thyPlacement: ThyPlacement;
29✔
353

29✔
354
    /**
355
     * 自定义 Overlay Origin
356
     */
357
    @Input()
40✔
358
    thyOrigin: ElementRef | HTMLElement;
40✔
359

38✔
360
    /**
361
     * 自定义 Footer 模板容器 class
40✔
362
     */
18✔
363
    @Input()
364
    thyFooterClass = 'thy-custom-select-footer';
365

22✔
366
    /**
2✔
367
     * @private
368
     */
369
    @ContentChild('selectedDisplay') selectedValueDisplayRef: TemplateRef<any>;
20✔
370

371
    /**
372
     * @private
40✔
373
     */
40✔
374
    @ContentChild('selectedOptionDisplay') selectedOptionDisplayRef: TemplateRef<any>;
375

83✔
376
    /**
93!
377
     * 初始化时,是否展开面板
93✔
378
     * @default false
83✔
379
     */
5✔
380
    @Input() @InputBoolean() thyAutoExpand: boolean;
381

78✔
382
    /**
72✔
383
     * 是否弹出透明遮罩,如果显示遮罩则会阻止滚动区域滚动
1✔
384
     */
385
    @Input()
71✔
386
    @InputBoolean()
387
    thyHasBackdrop = false;
388

6✔
389
    /**
390
     * 设置多选时最大显示的标签数量,0 表示不限制
391
     */
392
    @Input() @InputNumber() thyMaxTagCount = 0;
10!
UNCOV
393

×
394
    /**
395
     * 是否隐藏选择框边框
396
     * @default false
10✔
397
     */
398
    @Input() @InputBoolean() thyBorderless = false;
399

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

174✔
402
    @ViewChild('panel', { read: ElementRef }) panel: ElementRef<HTMLElement>;
3✔
403

404
    /**
174✔
405
     * @private
406
     */
407
    @ContentChildren(ThyOptionComponent, { descendants: true }) options: QueryList<ThyOptionComponent>;
408

409
    /**
174✔
UNCOV
410
     * @private
×
UNCOV
411
     */
×
412
    @ContentChildren(ThySelectOptionGroupComponent) optionGroups: QueryList<ThySelectOptionGroupComponent>;
413

174✔
414
    @HostListener('keydown', ['$event'])
91✔
415
    handleKeydown(event: KeyboardEvent): void {
19✔
416
        if (!this.disabled) {
18✔
417
            if (event.keyCode === ENTER) {
418
                event.stopPropagation();
419
            }
72✔
420
            this.panelOpen ? this.handleOpenKeydown(event) : this.handleClosedKeydown(event);
5✔
421
        }
422
    }
423

424
    get optionsChanges$() {
425
        let previousOptions: ThyOptionComponent[] = this.options.toArray();
4✔
426
        return this.options.changes.pipe(
4✔
427
            map(data => {
4✔
428
                return this.options.toArray();
4✔
429
            }),
430
            filter(data => {
4!
431
                const res = previousOptions.length !== data.length || previousOptions.some((op, index) => op !== data[index]);
1✔
432
                previousOptions = data;
1✔
433
                return res;
434
            })
3!
435
        );
3✔
436
    }
2✔
437

2✔
438
    private buildScrollStrategy() {
439
        if (this.scrollStrategyFactory && isFunction(this.scrollStrategyFactory)) {
440
            this.scrollStrategy = this.scrollStrategyFactory();
1✔
441
        } else {
442
            this.scrollStrategy = this.overlay.scrollStrategies.reposition();
443
        }
444
    }
445

12✔
446
    private isSearching = false;
12✔
447

12✔
448
    constructor(
12✔
449
        private ngZone: NgZone,
2✔
450
        private elementRef: ElementRef,
2✔
451
        private viewportRuler: ViewportRuler,
452
        private changeDetectorRef: ChangeDetectorRef,
10✔
453
        private overlay: Overlay,
454
        private thyClickDispatcher: ThyClickDispatcher,
1✔
455
        @Inject(PLATFORM_ID) private platformId: string,
1✔
456
        @Optional() @Inject(THY_SELECT_SCROLL_STRATEGY) public scrollStrategyFactory: FunctionProp<ScrollStrategy>,
457
        @Optional() @Inject(THY_SELECT_CONFIG) public selectConfig: ThySelectConfig
9✔
458
    ) {
3✔
459
        super();
3!
UNCOV
460
        this.config = { ...DEFAULT_SELECT_CONFIG, ...selectConfig };
×
UNCOV
461
        this.buildScrollStrategy();
×
462
    }
463

464
    writeValue(value: any): void {
3✔
465
        this.modalValue = value;
466
        this.setSelectionByModelValue(this.modalValue);
6!
UNCOV
467
    }
×
UNCOV
468

×
UNCOV
469
    ngOnInit() {
×
UNCOV
470
        this.getPositions();
×
UNCOV
471
        this.dropDownMinWidth = this.getDropdownMinWidth();
×
472
        this.viewportRuler
473
            .change()
474
            .pipe(takeUntil(this.destroy$))
475
            .subscribe(() => {
476
                if (this.panelOpen) {
6!
UNCOV
477
                    this.triggerRectWidth = this.getOriginRectWidth();
×
478
                    this.changeDetectorRef.markForCheck();
479
                }
6✔
480
            });
6✔
481
        if (!this.selectionModel) {
6!
482
            this.instanceSelectionModel();
483
        }
484
        this.setDropDownClass();
485

UNCOV
486
        if (isPlatformBrowser(this.platformId)) {
×
487
            this.thyClickDispatcher
488
                .clicked(0)
489
                .pipe(takeUntil(this.destroy$))
490
                .subscribe(event => {
491
                    if (!this.elementRef.nativeElement.contains(event.target) && this.panelOpen) {
258✔
492
                        this.ngZone.run(() => {
493
                            this.close();
494
                            this.changeDetectorRef.markForCheck();
172✔
495
                        });
7✔
496
                    }
497
                });
172✔
498
        }
172✔
499
    }
7✔
500

7✔
501
    getDropdownMinWidth(): number | null {
502
        const mode = this.thyDropdownWidthMode || this.config.dropdownWidthMode;
172✔
503
        let dropdownMinWidth: number | null = null;
91✔
504

91✔
505
        if ((mode as { minWidth: number })?.minWidth) {
506
            dropdownMinWidth = (mode as { minWidth: number }).minWidth;
507
        } else if (mode === 'min-width') {
508
            dropdownMinWidth = THY_SELECT_PANEL_MIN_WIDTH;
174✔
509
        } else {
174✔
510
            dropdownMinWidth = null;
93✔
511
        }
93✔
512

13✔
513
        return dropdownMinWidth;
13✔
514
    }
515

516
    ngAfterContentInit() {
517
        this.optionsChanges$.pipe(startWith(null), takeUntil(this.destroy$)).subscribe(data => {
518
            this.resetOptions();
174✔
519
            this.initializeSelection();
174✔
520
            this.initKeyManager();
521
            if (this.isSearching) {
522
                this.highlightCorrectOption(false);
523
                this.isSearching = false;
258✔
524
            }
258✔
525
            this.changeDetectorRef.markForCheck();
39✔
526
            this.ngZone.onStable
527
                .asObservable()
528
                .pipe(take(1))
219✔
529
                .subscribe(() => {
530
                    if (this.cdkConnectedOverlay && this.cdkConnectedOverlay.overlayRef) {
258✔
531
                        this.cdkConnectedOverlay.overlayRef.updatePosition();
532
                    }
533
                });
534
        });
535
        if (this.thyAutoExpand) {
536
            timer(0).subscribe(() => {
407✔
537
                this.changeDetectorRef.markForCheck();
232✔
538
                this.open();
1✔
539
                this.focus();
1✔
540
            });
541
        }
232✔
542
    }
543

175✔
544
    public get isHiddenOptions(): boolean {
24!
545
        return this.options.toArray().every(option => option.hidden);
24✔
546
    }
24✔
547

24✔
548
    public onAttached(): void {
185✔
549
        this.cdkConnectedOverlay.positionChange.pipe(take(1)).subscribe(() => {
2✔
550
            if (this.panel) {
25✔
551
                if (this.keyManager.activeItem) {
22✔
552
                    ScrollToService.scrollToElement(this.keyManager.activeItem.element.nativeElement, this.panel.nativeElement);
553
                    this.changeDetectorRef.detectChanges();
554
                } else {
555
                    if (!this.empty) {
556
                        ScrollToService.scrollToElement(this.selectionModel.selected[0].element.nativeElement, this.panel.nativeElement);
557
                        this.changeDetectorRef.detectChanges();
151✔
558
                    }
163✔
559
                }
560
            }
151✔
561
        });
123✔
562
    }
563

564
    public dropDownMouseMove(event: MouseEvent) {
175✔
565
        if (this.keyManager.activeItem) {
566
            this.keyManager.setActiveItem(-1);
567
        }
94✔
568
    }
94!
UNCOV
569

×
UNCOV
570
    public onOptionsScrolled(elementRef: ElementRef) {
×
571
        const scroll = elementRef.nativeElement.scrollTop,
572
            height = elementRef.nativeElement.clientHeight,
573
            scrollHeight = elementRef.nativeElement.scrollHeight;
94✔
574

37✔
575
        if (scroll + height + 10 >= scrollHeight) {
576
            if (this.thyOnScrollToBottom.observers.length > 0) {
94✔
577
                this.ngZone.run(() => {
32✔
578
                    this.thyOnScrollToBottom.emit();
579
                });
94✔
580
            }
49✔
581
        }
49✔
582
    }
12✔
583

584
    public onSearchFilter(searchText: string) {
585
        searchText = searchText.trim();
586
        if (this.thyServerSearch) {
94✔
587
            this.isSearching = true;
37✔
588
            this.thyOnSearch.emit(searchText);
589
        } else {
94✔
590
            const options = this.options.toArray();
45✔
591
            options.forEach(option => {
592
                if (option.matchSearchText(searchText)) {
94✔
593
                    option.showOption();
594
                } else {
595
                    option.hideOption();
49!
596
                }
49✔
597
            });
49✔
598
            this.highlightCorrectOption(false);
2✔
599
            this.updateCdkConnectedOverlayPositions();
1✔
600
        }
601
    }
602

603
    onBlur(event?: FocusEvent) {
604
        // Tab 聚焦后自动聚焦到 input 输入框,此分支下直接返回,无需触发 onTouchedFn
605
        if (elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-select-dropdown', 'thy-custom-select'])) {
84✔
606
            return;
607
        }
608
        this.onTouchedFn();
166✔
609
    }
166✔
610

611
    onFocus(event?: FocusEvent) {
1✔
612
        // thyShowSearch 与 panelOpen 均为 true 时,点击 thySelectControl 需要触发自动聚焦到 input 的逻辑
613
        // manualFocusing 如果是手动聚焦,不触发自动聚焦到 input 的逻辑
614
        if (
615
            (this.thyShowSearch && this.panelOpen) ||
616
            (!this.manualFocusing &&
617
                !elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-select-dropdown', 'thy-custom-select']))
618
        ) {
619
            const inputElement: HTMLInputElement = this.elementRef.nativeElement.querySelector('input');
620
            inputElement.focus();
621
        }
622
        this.manualFocusing = false;
1✔
623
    }
624

625
    public focus(options?: FocusOptions): void {
626
        this.manualFocusing = true;
627
        this.elementRef.nativeElement.focus(options);
628
        this.manualFocusing = false;
629
    }
630

631
    public remove($event: { item: ThyOptionComponent; $eventOrigin: Event }) {
632
        $event.$eventOrigin.stopPropagation();
633
        if (this.disabled) {
634
            return;
635
        }
636
        if (!this.options.find(option => option === $event.item)) {
637
            $event.item.deselect();
638
            // fix option unselect can not emit changes;
639
            this.onSelect($event.item, true);
640
        } else {
641
            $event.item.deselect();
642
        }
643
    }
644

645
    public clearSelectValue(event?: Event) {
646
        if (event) {
647
            event.stopPropagation();
648
        }
649
        if (this.disabled) {
650
            return;
651
        }
652
        this.selectionModel.clear();
653
        this.changeDetectorRef.markForCheck();
654
        this.emitModelValueChange();
655
    }
656

657
    public updateCdkConnectedOverlayPositions(): void {
658
        setTimeout(() => {
659
            if (this.cdkConnectedOverlay && this.cdkConnectedOverlay.overlayRef) {
660
                this.cdkConnectedOverlay.overlayRef.updatePosition();
661
            }
1✔
662
        });
663
    }
664

665
    public get selected(): ThyOptionComponent | ThyOptionComponent[] {
1✔
666
        return this.isMultiple ? this.selectionModel.selected : this.selectionModel.selected[0];
667
    }
668

669
    public get isMultiple(): boolean {
1✔
670
        return this.mode === 'multiple';
671
    }
672

673
    public get empty(): boolean {
1✔
674
        return !this.selectionModel || this.selectionModel.isEmpty();
675
    }
676

677
    public getItemCount(): number {
1✔
678
        return this.options.length + this.optionGroups.length;
679
    }
680

681
    public toggle(event: MouseEvent): void {
1✔
682
        if (this.panelOpen) {
683
            if (!this.thyShowSearch) {
684
                this.close();
685
            }
1✔
686
        } else {
687
            this.open();
688
        }
689
    }
690

1✔
691
    public open(): void {
692
        if (this.disabled || !this.options || this.panelOpen) {
693
            return;
694
        }
1✔
695
        this.triggerRectWidth = this.getOriginRectWidth();
696
        this.panelOpen = true;
697
        this.highlightCorrectOption();
698
        this.thyOnExpandStatusChange.emit(this.panelOpen);
1✔
699
    }
700

701
    public close(): void {
702
        if (this.panelOpen) {
1✔
703
            this.panelOpen = false;
704
            this.thyOnExpandStatusChange.emit(this.panelOpen);
705
            this.focus();
706
            this.changeDetectorRef.markForCheck();
1✔
707
            this.onTouchedFn();
708
        }
709
    }
710

711
    private emitModelValueChange() {
712
        const selectedValues = this.selectionModel.selected;
713
        const changeValue = selectedValues.map((option: ThyOptionComponent) => {
714
            return option.thyValue;
715
        });
716
        if (this.isMultiple) {
717
            this.modalValue = changeValue;
718
        } else {
149✔
719
            if (changeValue.length === 0) {
720
                this.modalValue = null;
721
            } else {
722
                this.modalValue = changeValue[0];
723
            }
724
        }
725
        this.onChangeFn(this.modalValue);
726
        this.updateCdkConnectedOverlayPositions();
727
    }
728

729
    private highlightCorrectOption(fromOpenPanel: boolean = true): void {
730
        if (this.keyManager && this.panelOpen) {
731
            if (fromOpenPanel) {
732
                if (this.keyManager.activeItem) {
733
                    return;
734
                }
735
                if (this.empty) {
736
                    if (!this.thyAutoActiveFirstItem) {
737
                        return;
738
                    }
739
                    this.keyManager.setFirstItemActive();
740
                } else {
741
                    this.keyManager.setActiveItem(this.selectionModel.selected[0]);
742
                }
743
            } else {
744
                if (!this.thyAutoActiveFirstItem) {
745
                    return;
746
                }
747
                // always set first option active
748
                this.keyManager.setFirstItemActive();
749
            }
750
        }
751
    }
752

753
    private initKeyManager() {
754
        if (this.keyManager && this.keyManager.activeItem) {
755
            this.keyManager.activeItem.setInactiveStyles();
756
        }
757
        this.keyManager = new ActiveDescendantKeyManager<ThyOptionComponent>(this.options)
758
            .withTypeAhead()
759
            .withWrap()
760
            .withVerticalOrientation()
761
            .withAllowedModifierKeys(['shiftKey']);
762

763
        this.keyManager.tabOut.pipe(takeUntil(this.destroy$)).subscribe(() => {
764
            this.focus();
765
            this.close();
766
        });
767
        this.keyManager.change.pipe(takeUntil(this.destroy$)).subscribe(() => {
768
            if (this.panelOpen && this.panel) {
769
                if (this.keyManager.activeItem) {
770
                    ScrollToService.scrollToElement(this.keyManager.activeItem.element.nativeElement, this.panel.nativeElement);
771
                }
772
            } else if (!this.panelOpen && !this.isMultiple && this.keyManager.activeItem) {
773
                this.keyManager.activeItem.selectViaInteraction();
774
            }
775
        });
776
    }
777

778
    private handleClosedKeydown(event: KeyboardEvent): void {
779
        const keyCode = event.keyCode;
780
        const isArrowKey = keyCode === DOWN_ARROW || keyCode === UP_ARROW || keyCode === LEFT_ARROW || keyCode === RIGHT_ARROW;
781
        const isOpenKey = keyCode === ENTER || keyCode === SPACE;
782
        const manager = this.keyManager;
783

784
        // Open the select on ALT + arrow key to match the native <select>
785
        if ((isOpenKey && !hasModifierKey(event)) || ((this.isMultiple || event.altKey) && isArrowKey)) {
786
            event.preventDefault(); // prevents the page from scrolling down when pressing space
787
            this.open();
788
        } else if (!this.isMultiple) {
789
            if (keyCode === HOME || keyCode === END) {
790
                keyCode === HOME ? manager.setFirstItemActive() : manager.setLastItemActive();
791
                event.preventDefault();
792
            } else {
793
                manager.onKeydown(event);
794
            }
795
        }
796
    }
797

798
    private handleOpenKeydown(event: KeyboardEvent): void {
799
        const keyCode = event.keyCode;
800
        const isArrowKey = keyCode === DOWN_ARROW || keyCode === UP_ARROW;
801
        const manager = this.keyManager;
802

803
        if (keyCode === HOME || keyCode === END) {
804
            event.preventDefault();
805
            keyCode === HOME ? manager.setFirstItemActive() : manager.setLastItemActive();
806
        } else if (isArrowKey && event.altKey) {
807
            // Close the select on ALT + arrow key to match the native <select>
808
            event.preventDefault();
809
            this.close();
810
        } else if ((keyCode === ENTER || keyCode === SPACE) && (manager.activeItem || !this.empty) && !hasModifierKey(event)) {
811
            event.preventDefault();
812
            if (!manager.activeItem) {
813
                if (manager.activeItemIndex === -1 && !this.empty) {
814
                    manager.setActiveItem(this.selectionModel.selected[0]);
815
                }
816
            }
817
            manager.activeItem.selectViaInteraction();
818
        } else if (this.isMultiple && keyCode === A && event.ctrlKey) {
819
            event.preventDefault();
820
            const hasDeselectedOptions = this.options.some(opt => !opt.disabled && !opt.selected);
821

822
            this.options.forEach(option => {
823
                if (!option.disabled) {
824
                    hasDeselectedOptions ? option.select() : option.deselect();
825
                }
826
            });
827
        } else {
828
            if (manager.activeItemIndex === -1 && !this.empty) {
829
                manager.setActiveItem(this.selectionModel.selected[0]);
830
            }
831
            const previouslyFocusedIndex = manager.activeItemIndex;
832

833
            manager.onKeydown(event);
834

835
            if (
836
                this.isMultiple &&
837
                isArrowKey &&
838
                event.shiftKey &&
839
                manager.activeItem &&
840
                manager.activeItemIndex !== previouslyFocusedIndex
841
            ) {
842
                manager.activeItem.selectViaInteraction();
843
            }
844
        }
845
    }
846

847
    private getPositions() {
848
        this.dropDownPositions = getFlexiblePositions(this.thyPlacement || this.config.placement, this.defaultOffset);
849
    }
850

851
    private instanceSelectionModel() {
852
        if (this.selectionModel) {
853
            this.selectionModel.clear();
854
        }
855
        this.selectionModel = new SelectionModel<ThyOptionComponent>(this.isMultiple);
856
        if (this.selectionModelSubscription) {
857
            this.selectionModelSubscription.unsubscribe();
858
            this.selectionModelSubscription = null;
859
        }
860
        this.selectionModelSubscription = this.selectionModel.changed.pipe(takeUntil(this.destroy$)).subscribe(event => {
861
            event.added.forEach(option => option.select());
862
            event.removed.forEach(option => option.deselect());
863
        });
864
    }
865

866
    private resetOptions() {
867
        const changedOrDestroyed$ = merge(this.optionsChanges$, this.destroy$);
868

869
        this.optionSelectionChanges.pipe(takeUntil(changedOrDestroyed$)).subscribe((event: ThyOptionSelectionChangeEvent) => {
870
            this.onSelect(event.option, event.isUserInput);
871
            if (event.isUserInput && !this.isMultiple && this.panelOpen) {
872
                this.close();
873
                this.focus();
874
            }
875
        });
876
    }
877

878
    private initializeSelection() {
879
        Promise.resolve().then(() => {
880
            this.setSelectionByModelValue(this.modalValue);
881
        });
882
    }
883

884
    private setDropDownClass() {
885
        let modeClass = '';
886
        if (this.isMultiple) {
887
            modeClass = `thy-select-dropdown-${this.mode}`;
888
        } else {
889
            modeClass = `thy-select-dropdown-single`;
890
        }
891
        this.dropDownClass = {
892
            [`thy-select-dropdown`]: true,
893
            [modeClass]: true
894
        };
895
    }
896

897
    private setSelectionByModelValue(modalValue: any) {
898
        if (helpers.isUndefinedOrNull(modalValue)) {
899
            if (this.selectionModel.selected.length > 0) {
900
                this.selectionModel.clear();
901
                this.changeDetectorRef.markForCheck();
902
            }
903
            return;
904
        }
905
        if (this.isMultiple) {
906
            if (isArray(modalValue)) {
907
                const selected = [...this.selectionModel.selected];
908
                this.selectionModel.clear();
909
                (modalValue as Array<any>).forEach(itemValue => {
910
                    const option =
911
                        this.options.find(_option => _option.thyValue === itemValue) ||
912
                        selected.find(_option => _option.thyValue === itemValue);
913
                    if (option) {
914
                        this.selectionModel.select(option);
915
                    }
916
                });
917
            }
918
        } else {
919
            const selectedOption = this.options?.find(option => {
920
                return option.thyValue === modalValue;
921
            });
922
            if (selectedOption) {
923
                this.selectionModel.select(selectedOption);
924
            }
925
        }
926
        this.changeDetectorRef.markForCheck();
927
    }
928

929
    private onSelect(option: ThyOptionComponent, isUserInput: boolean) {
930
        const wasSelected = this.selectionModel.isSelected(option);
931

932
        if (option.thyValue == null && !this.isMultiple) {
933
            option.deselect();
934
            this.selectionModel.clear();
935
        } else {
936
            if (wasSelected !== option.selected) {
937
                option.selected ? this.selectionModel.select(option) : this.selectionModel.deselect(option);
938
            }
939

940
            if (isUserInput) {
941
                this.keyManager.setActiveItem(option);
942
            }
943

944
            if (this.isMultiple) {
945
                this.sortValues();
946
                if (isUserInput) {
947
                    this.focus();
948
                }
949
            }
950
        }
951

952
        if (wasSelected !== this.selectionModel.isSelected(option)) {
953
            this.emitModelValueChange();
954
        }
955
        if (!this.isMultiple) {
956
            this.onTouchedFn();
957
        }
958
        this.changeDetectorRef.markForCheck();
959
    }
960

961
    private sortValues() {
962
        if (this.isMultiple) {
963
            const options = this.options.toArray();
964

965
            if (this.thySortComparator) {
966
                this.selectionModel.sort((a, b) => {
967
                    return this.thySortComparator(a, b, options);
968
                });
969
            }
970
        }
971
    }
972

973
    private getOriginRectWidth() {
974
        return this.thyOrigin ? coerceElement(this.thyOrigin).offsetWidth : this.trigger.nativeElement.offsetWidth;
975
    }
976

977
    ngOnDestroy() {
978
        this.destroy$.next();
979
        this.destroy$.complete();
980
    }
981
}
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