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

atinc / ngx-tethys / 0bbb2cec-209e-4d8a-b1b3-6bc54e05daa6

04 Sep 2023 08:40AM UTC coverage: 15.616% (-74.6%) from 90.2%
0bbb2cec-209e-4d8a-b1b3-6bc54e05daa6

Pull #2829

circleci

cmm-va
fix: add test
Pull Request #2829: fix: add tabIndex

300 of 6386 branches covered (0.0%)

Branch coverage included in aggregate %.

78 of 78 new or added lines in 26 files covered. (100.0%)

2849 of 13779 relevant lines covered (20.68%)

83.41 hits per line

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

3.64
/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,
×
33
    isArray,
×
34
    isFunction,
×
35
    LEFT_ARROW,
×
36
    RIGHT_ARROW,
37
    SPACE,
38
    UP_ARROW
×
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';
×
42

43
import { ActiveDescendantKeyManager } from '@angular/cdk/a11y';
44
import { coerceBooleanProperty, coerceElement } from '@angular/cdk/coercion';
×
45
import { SelectionModel } from '@angular/cdk/collections';
46
import {
47
    CdkConnectedOverlay,
×
48
    CdkOverlayOrigin,
49
    ConnectionPositionPair,
50
    Overlay,
×
51
    ScrollStrategy,
52
    ViewportRuler
53
} from '@angular/cdk/overlay';
×
54
import { isPlatformBrowser, NgClass, NgIf, NgTemplateOutlet } from '@angular/common';
55
import {
56
    AfterContentInit,
×
57
    ChangeDetectionStrategy,
58
    ChangeDetectorRef,
59
    Component,
×
60
    ContentChild,
×
61
    ContentChildren,
×
62
    ElementRef,
63
    EventEmitter,
×
64
    forwardRef,
65
    HostBinding,
66
    HostListener,
67
    Inject,
×
68
    Input,
×
69
    NgZone,
×
70
    OnDestroy,
71
    OnInit,
×
72
    Optional,
×
73
    Output,
×
74
    PLATFORM_ID,
75
    QueryList,
76
    TemplateRef,
77
    ViewChild
×
78
} from '@angular/core';
×
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,
×
86
    DEFAULT_SELECT_CONFIG
×
87
} from '../select.config';
×
88

×
89
export type SelectMode = 'multiple' | '';
×
90

×
91
export type ThyCustomSelectTriggerType = 'click' | 'hover';
×
92

×
93
export const SELECT_PANEL_MAX_HEIGHT = 300;
×
94

×
95
export const SELECT_OPTION_MAX_HEIGHT = 40;
×
96

×
97
export const SELECT_OPTION_GROUP_MAX_HEIGHT = 30;
×
98

×
99
export const SELECT_PANEL_PADDING_TOP = 10;
×
100

×
101
export const THY_SELECT_PANEL_MIN_WIDTH = 200;
×
102

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

×
111
const noop = () => {};
112

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

×
159
    size: SelectControlSize;
×
160

×
161
    mode: SelectMode = '';
162

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

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

167
    scrollTop = 0;
×
168

×
169
    modalValue: any = null;
×
170

×
171
    defaultOffset = 4;
172

×
173
    dropDownClass: { [key: string]: boolean };
×
174

175
    dropDownMinWidth: number | null = null;
176

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

×
183
    public dropDownPositions: ConnectionPositionPair[];
×
184

×
185
    public selectionModel: SelectionModel<ThyOptionComponent>;
×
186

×
187
    public triggerRectWidth: number;
×
188

189
    public scrollStrategy: ScrollStrategy;
×
190

×
191
    private selectionModelSubscription: Subscription;
192

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

198
    private config: ThySelectConfig;
199

×
200
    private readonly destroy$ = new Subject<void>();
×
201

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

×
212
    @ViewChild(CdkConnectedOverlay, { static: true }) cdkConnectedOverlay: CdkConnectedOverlay;
×
213

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

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

218
    keyManager: ActiveDescendantKeyManager<ThyOptionComponent>;
×
219

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

×
371
    /**
372
     * 初始化时,是否展开面板
×
373
     * @default false
×
374
     */
×
375
    @Input() @InputBoolean() thyAutoExpand: boolean;
376

×
377
    /**
378
     * 是否弹出透明遮罩,如果显示遮罩则会阻止滚动区域滚动
379
     */
×
380
    @Input()
381
    @InputBoolean()
382
    thyHasBackdrop = false;
383

×
384
    /**
×
385
     * 设置多选时最大显示的标签数量,0 表示不限制
386
     */
387
    @Input() @InputNumber() thyMaxTagCount = 0;
×
388

389
    /**
390
     * 是否隐藏选择框边框
391
     * @default false
392
     */
×
393
    @Input() @InputBoolean() thyBorderless = false;
×
394

395
    @ViewChild('trigger', { read: ElementRef, static: true }) trigger: ElementRef<HTMLElement>;
×
396

397
    @ViewChild('panel', { read: ElementRef }) panel: ElementRef<HTMLElement>;
398

399
    /**
400
     * @private
×
401
     */
×
402
    @ContentChildren(ThyOptionComponent, { descendants: true }) options: QueryList<ThyOptionComponent>;
×
403

404
    /**
×
405
     * @private
×
406
     */
×
407
    @ContentChildren(ThySelectOptionGroupComponent) optionGroups: QueryList<ThySelectOptionGroupComponent>;
×
408

409
    @HostListener('keydown', ['$event'])
410
    handleKeydown(event: KeyboardEvent): void {
×
411
        if (!this.disabled) {
×
412
            if (event.keyCode === ENTER) {
413
                event.stopPropagation();
414
            }
415
            this.panelOpen ? this.handleOpenKeydown(event) : this.handleClosedKeydown(event);
416
        }
×
417
    }
×
418

×
419
    get optionsChanges$() {
×
420
        let previousOptions: ThyOptionComponent[] = this.options.toArray();
421
        return this.options.changes.pipe(
×
422
            map(data => {
×
423
                return this.options.toArray();
×
424
            }),
425
            filter(data => {
×
426
                const res = previousOptions.length !== data.length || previousOptions.some((op, index) => op !== data[index]);
×
427
                previousOptions = data;
×
428
                return res;
×
429
            })
430
        );
431
    }
×
432

433
    private buildScrollStrategy() {
434
        if (this.scrollStrategyFactory && isFunction(this.scrollStrategyFactory)) {
435
            this.scrollStrategy = this.scrollStrategyFactory();
436
        } else {
×
437
            this.scrollStrategy = this.overlay.scrollStrategies.reposition();
×
438
        }
×
439
    }
×
440

×
441
    private isSearching = false;
×
442

443
    constructor(
×
444
        private ngZone: NgZone,
445
        private elementRef: ElementRef,
×
446
        private viewportRuler: ViewportRuler,
×
447
        private changeDetectorRef: ChangeDetectorRef,
448
        private overlay: Overlay,
×
449
        private thyClickDispatcher: ThyClickDispatcher,
×
450
        @Inject(PLATFORM_ID) private platformId: string,
×
451
        @Optional() @Inject(THY_SELECT_SCROLL_STRATEGY) public scrollStrategyFactory: FunctionProp<ScrollStrategy>,
×
452
        @Optional() @Inject(THY_SELECT_CONFIG) public selectConfig: ThySelectConfig
×
453
    ) {
454
        super();
455
        this.config = { ...DEFAULT_SELECT_CONFIG, ...selectConfig };
×
456
        this.buildScrollStrategy();
457
    }
×
458

×
459
    writeValue(value: any): void {
×
460
        this.modalValue = value;
×
461
        this.setSelectionByModelValue(this.modalValue);
×
462
    }
×
463

464
    ngOnInit() {
465
        this.getPositions();
466
        this.dropDownMinWidth = this.getDropdownMinWidth();
467
        this.viewportRuler
×
468
            .change()
×
469
            .pipe(takeUntil(this.destroy$))
470
            .subscribe(() => {
×
471
                if (this.panelOpen) {
×
472
                    this.triggerRectWidth = this.getOriginRectWidth();
×
473
                    this.changeDetectorRef.markForCheck();
474
                }
475
            });
476
        if (!this.selectionModel) {
477
            this.instanceSelectionModel();
×
478
        }
479
        this.setDropDownClass();
480

481
        if (isPlatformBrowser(this.platformId)) {
482
            this.thyClickDispatcher
×
483
                .clicked(0)
484
                .pipe(takeUntil(this.destroy$))
485
                .subscribe(event => {
×
486
                    if (!this.elementRef.nativeElement.contains(event.target) && this.panelOpen) {
×
487
                        this.ngZone.run(() => {
488
                            this.close();
×
489
                            this.changeDetectorRef.markForCheck();
×
490
                        });
×
491
                    }
×
492
                });
493
        }
×
494
    }
×
495

×
496
    getDropdownMinWidth(): number | null {
497
        const mode = this.thyDropdownWidthMode || this.config.dropdownWidthMode;
498
        let dropdownMinWidth: number | null = null;
499

×
500
        if ((mode as { minWidth: number })?.minWidth) {
×
501
            dropdownMinWidth = (mode as { minWidth: number }).minWidth;
×
502
        } else if (mode === 'min-width') {
×
503
            dropdownMinWidth = THY_SELECT_PANEL_MIN_WIDTH;
×
504
        } else {
×
505
            dropdownMinWidth = null;
506
        }
507

508
        return dropdownMinWidth;
509
    }
×
510

×
511
    ngAfterContentInit() {
512
        this.optionsChanges$.pipe(startWith(null), takeUntil(this.destroy$)).subscribe(data => {
513
            this.resetOptions();
514
            this.initializeSelection();
×
515
            this.initKeyManager();
×
516
            if (this.isSearching) {
×
517
                this.highlightCorrectOption(false);
518
                this.isSearching = false;
519
            }
×
520
            this.changeDetectorRef.markForCheck();
521
            this.ngZone.onStable
×
522
                .asObservable()
523
                .pipe(take(1))
524
                .subscribe(() => {
525
                    if (this.cdkConnectedOverlay && this.cdkConnectedOverlay.overlayRef) {
526
                        this.cdkConnectedOverlay.overlayRef.updatePosition();
527
                    }
×
528
                });
×
529
        });
×
530
        if (this.thyAutoExpand) {
×
531
            timer(0).subscribe(() => {
532
                this.changeDetectorRef.markForCheck();
×
533
                this.open();
534
                this.focus();
×
535
            });
×
536
        }
×
537
    }
×
538

×
539
    public get isHiddenOptions(): boolean {
×
540
        return this.options.toArray().every(option => option.hidden);
×
541
    }
×
542

×
543
    public onAttached(): void {
544
        this.cdkConnectedOverlay.positionChange.pipe(take(1)).subscribe(() => {
545
            if (this.panel) {
546
                if (this.keyManager.activeItem) {
547
                    ScrollToService.scrollToElement(this.keyManager.activeItem.element.nativeElement, this.panel.nativeElement);
548
                    this.changeDetectorRef.detectChanges();
×
549
                } else {
×
550
                    if (!this.empty) {
551
                        ScrollToService.scrollToElement(this.selectionModel.selected[0].element.nativeElement, this.panel.nativeElement);
×
552
                        this.changeDetectorRef.detectChanges();
×
553
                    }
554
                }
555
            }
×
556
        });
557
    }
558

×
559
    public dropDownMouseMove(event: MouseEvent) {
×
560
        if (this.keyManager.activeItem) {
×
561
            this.keyManager.setActiveItem(-1);
×
562
        }
563
    }
564

×
565
    public onOptionsScrolled(elementRef: ElementRef) {
×
566
        const scroll = elementRef.nativeElement.scrollTop,
567
            height = elementRef.nativeElement.clientHeight,
×
568
            scrollHeight = elementRef.nativeElement.scrollHeight;
×
569

570
        if (scroll + height + 10 >= scrollHeight) {
×
571
            if (this.thyOnScrollToBottom.observers.length > 0) {
×
572
                this.ngZone.run(() => {
×
573
                    this.thyOnScrollToBottom.emit();
×
574
                });
575
            }
576
        }
577
    }
×
578

×
579
    public onSearchFilter(searchText: string) {
580
        searchText = searchText.trim();
×
581
        if (this.thyServerSearch) {
×
582
            this.isSearching = true;
583
            this.thyOnSearch.emit(searchText);
×
584
        } else {
585
            const options = this.options.toArray();
586
            options.forEach(option => {
×
587
                if (option.matchSearchText(searchText)) {
×
588
                    option.showOption();
×
589
                } else {
×
590
                    option.hideOption();
×
591
                }
592
            });
593
            this.highlightCorrectOption(false);
594
            this.updateCdkConnectedOverlayPositions();
595
        }
596
    }
×
597

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

606
    onFocus(event?: FocusEvent) {
607
        // manualFocusing 如果是手动聚焦,不触发自动聚焦到 input 的逻辑
608
        if (
609
            !this.manualFocusing &&
610
            !elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-select-dropdown', 'thy-custom-select'])
611
        ) {
612
            const inputElement: HTMLInputElement = this.elementRef.nativeElement.querySelector('input');
613
            inputElement.focus();
1✔
614
        }
615
        this.manualFocusing = false;
616
    }
617

618
    public focus(options?: FocusOptions): void {
619
        this.manualFocusing = true;
620
        this.elementRef.nativeElement.focus(options);
621
        this.manualFocusing = false;
622
    }
623

624
    public remove($event: { item: ThyOptionComponent; $eventOrigin: Event }) {
625
        $event.$eventOrigin.stopPropagation();
626
        if (this.disabled) {
627
            return;
628
        }
629
        if (!this.options.find(option => option === $event.item)) {
630
            $event.item.deselect();
631
            // fix option unselect can not emit changes;
632
            this.onSelect($event.item, true);
633
        } else {
634
            $event.item.deselect();
635
        }
636
    }
637

638
    public clearSelectValue(event?: Event) {
639
        if (event) {
640
            event.stopPropagation();
641
        }
642
        if (this.disabled) {
643
            return;
644
        }
645
        this.selectionModel.clear();
646
        this.changeDetectorRef.markForCheck();
647
        this.emitModelValueChange();
648
    }
649

650
    public updateCdkConnectedOverlayPositions(): void {
651
        setTimeout(() => {
1✔
652
            if (this.cdkConnectedOverlay && this.cdkConnectedOverlay.overlayRef) {
653
                this.cdkConnectedOverlay.overlayRef.updatePosition();
654
            }
655
        });
1✔
656
    }
657

658
    public get selected(): ThyOptionComponent | ThyOptionComponent[] {
659
        return this.isMultiple ? this.selectionModel.selected : this.selectionModel.selected[0];
1✔
660
    }
661

662
    public get isMultiple(): boolean {
663
        return this.mode === 'multiple';
1✔
664
    }
665

666
    public get empty(): boolean {
667
        return !this.selectionModel || this.selectionModel.isEmpty();
1✔
668
    }
669

670
    public getItemCount(): number {
671
        return this.options.length + this.optionGroups.length;
1✔
672
    }
673

674
    public toggle(event: MouseEvent): void {
675
        this.panelOpen ? this.close() : this.open();
1✔
676
    }
677

678
    public open(): void {
679
        if (this.disabled || !this.options || this.panelOpen) {
680
            return;
1✔
681
        }
682
        this.triggerRectWidth = this.getOriginRectWidth();
683
        this.panelOpen = true;
684
        this.highlightCorrectOption();
1✔
685
        this.thyOnExpandStatusChange.emit(this.panelOpen);
686
    }
687

688
    public close(): void {
1✔
689
        if (this.panelOpen) {
690
            this.panelOpen = false;
691
            this.thyOnExpandStatusChange.emit(this.panelOpen);
692
            this.focus();
1✔
693
            this.changeDetectorRef.markForCheck();
694
            this.onTouchedFn();
695
        }
696
    }
1✔
697

698
    private emitModelValueChange() {
699
        const selectedValues = this.selectionModel.selected;
700
        const changeValue = selectedValues.map((option: ThyOptionComponent) => {
701
            return option.thyValue;
702
        });
703
        if (this.isMultiple) {
704
            this.modalValue = changeValue;
705
        } else {
706
            if (changeValue.length === 0) {
707
                this.modalValue = null;
708
            } else {
×
709
                this.modalValue = changeValue[0];
710
            }
711
        }
712
        this.onChangeFn(this.modalValue);
713
        this.updateCdkConnectedOverlayPositions();
714
    }
715

716
    private highlightCorrectOption(fromOpenPanel: boolean = true): void {
717
        if (this.keyManager && this.panelOpen) {
718
            if (fromOpenPanel) {
719
                if (this.keyManager.activeItem) {
720
                    return;
721
                }
722
                if (this.empty) {
723
                    if (!this.thyAutoActiveFirstItem) {
724
                        return;
725
                    }
726
                    this.keyManager.setFirstItemActive();
727
                } else {
728
                    this.keyManager.setActiveItem(this.selectionModel.selected[0]);
729
                }
730
            } else {
731
                if (!this.thyAutoActiveFirstItem) {
732
                    return;
733
                }
734
                // always set first option active
735
                this.keyManager.setFirstItemActive();
736
            }
737
        }
738
    }
739

740
    private initKeyManager() {
741
        if (this.keyManager && this.keyManager.activeItem) {
742
            this.keyManager.activeItem.setInactiveStyles();
743
        }
744
        this.keyManager = new ActiveDescendantKeyManager<ThyOptionComponent>(this.options)
745
            .withTypeAhead()
746
            .withWrap()
747
            .withVerticalOrientation()
748
            .withAllowedModifierKeys(['shiftKey']);
749

750
        this.keyManager.tabOut.pipe(takeUntil(this.destroy$)).subscribe(() => {
751
            this.focus();
752
            this.close();
753
        });
754
        this.keyManager.change.pipe(takeUntil(this.destroy$)).subscribe(() => {
755
            if (this.panelOpen && this.panel) {
756
                if (this.keyManager.activeItem) {
757
                    ScrollToService.scrollToElement(this.keyManager.activeItem.element.nativeElement, this.panel.nativeElement);
758
                }
759
            } else if (!this.panelOpen && !this.isMultiple && this.keyManager.activeItem) {
760
                this.keyManager.activeItem.selectViaInteraction();
761
            }
762
        });
763
    }
764

765
    private handleClosedKeydown(event: KeyboardEvent): void {
766
        const keyCode = event.keyCode;
767
        const isArrowKey = keyCode === DOWN_ARROW || keyCode === UP_ARROW || keyCode === LEFT_ARROW || keyCode === RIGHT_ARROW;
768
        const isOpenKey = keyCode === ENTER || keyCode === SPACE;
769
        const manager = this.keyManager;
770

771
        // Open the select on ALT + arrow key to match the native <select>
772
        if ((isOpenKey && !hasModifierKey(event)) || ((this.isMultiple || event.altKey) && isArrowKey)) {
773
            event.preventDefault(); // prevents the page from scrolling down when pressing space
774
            this.open();
775
        } else if (!this.isMultiple) {
776
            if (keyCode === HOME || keyCode === END) {
777
                keyCode === HOME ? manager.setFirstItemActive() : manager.setLastItemActive();
778
                event.preventDefault();
779
            } else {
780
                manager.onKeydown(event);
781
            }
782
        }
783
    }
784

785
    private handleOpenKeydown(event: KeyboardEvent): void {
786
        const keyCode = event.keyCode;
787
        const isArrowKey = keyCode === DOWN_ARROW || keyCode === UP_ARROW;
788
        const manager = this.keyManager;
789

790
        if (keyCode === HOME || keyCode === END) {
791
            event.preventDefault();
792
            keyCode === HOME ? manager.setFirstItemActive() : manager.setLastItemActive();
793
        } else if (isArrowKey && event.altKey) {
794
            // Close the select on ALT + arrow key to match the native <select>
795
            event.preventDefault();
796
            this.close();
797
        } else if ((keyCode === ENTER || keyCode === SPACE) && (manager.activeItem || !this.empty) && !hasModifierKey(event)) {
798
            event.preventDefault();
799
            if (!manager.activeItem) {
800
                if (manager.activeItemIndex === -1 && !this.empty) {
801
                    manager.setActiveItem(this.selectionModel.selected[0]);
802
                }
803
            }
804
            manager.activeItem.selectViaInteraction();
805
        } else if (this.isMultiple && keyCode === A && event.ctrlKey) {
806
            event.preventDefault();
807
            const hasDeselectedOptions = this.options.some(opt => !opt.disabled && !opt.selected);
808

809
            this.options.forEach(option => {
810
                if (!option.disabled) {
811
                    hasDeselectedOptions ? option.select() : option.deselect();
812
                }
813
            });
814
        } else {
815
            if (manager.activeItemIndex === -1 && !this.empty) {
816
                manager.setActiveItem(this.selectionModel.selected[0]);
817
            }
818
            const previouslyFocusedIndex = manager.activeItemIndex;
819

820
            manager.onKeydown(event);
821

822
            if (
823
                this.isMultiple &&
824
                isArrowKey &&
825
                event.shiftKey &&
826
                manager.activeItem &&
827
                manager.activeItemIndex !== previouslyFocusedIndex
828
            ) {
829
                manager.activeItem.selectViaInteraction();
830
            }
831
        }
832
    }
833

834
    private getPositions() {
835
        this.dropDownPositions = getFlexiblePositions(this.thyPlacement || this.config.placement, this.defaultOffset);
836
    }
837

838
    private instanceSelectionModel() {
839
        if (this.selectionModel) {
840
            this.selectionModel.clear();
841
        }
842
        this.selectionModel = new SelectionModel<ThyOptionComponent>(this.isMultiple);
843
        if (this.selectionModelSubscription) {
844
            this.selectionModelSubscription.unsubscribe();
845
            this.selectionModelSubscription = null;
846
        }
847
        this.selectionModelSubscription = this.selectionModel.changed.pipe(takeUntil(this.destroy$)).subscribe(event => {
848
            event.added.forEach(option => option.select());
849
            event.removed.forEach(option => option.deselect());
850
        });
851
    }
852

853
    private resetOptions() {
854
        const changedOrDestroyed$ = merge(this.optionsChanges$, this.destroy$);
855

856
        this.optionSelectionChanges.pipe(takeUntil(changedOrDestroyed$)).subscribe((event: ThyOptionSelectionChangeEvent) => {
857
            this.onSelect(event.option, event.isUserInput);
858
            if (event.isUserInput && !this.isMultiple && this.panelOpen) {
859
                this.close();
860
                this.focus();
861
            }
862
        });
863
    }
864

865
    private initializeSelection() {
866
        Promise.resolve().then(() => {
867
            this.setSelectionByModelValue(this.modalValue);
868
        });
869
    }
870

871
    private setDropDownClass() {
872
        let modeClass = '';
873
        if (this.isMultiple) {
874
            modeClass = `thy-select-dropdown-${this.mode}`;
875
        } else {
876
            modeClass = `thy-select-dropdown-single`;
877
        }
878
        this.dropDownClass = {
879
            [`thy-select-dropdown`]: true,
880
            [modeClass]: true
881
        };
882
    }
883

884
    private setSelectionByModelValue(modalValue: any) {
885
        if (helpers.isUndefinedOrNull(modalValue)) {
886
            if (this.selectionModel.selected.length > 0) {
887
                this.selectionModel.clear();
888
                this.changeDetectorRef.markForCheck();
889
            }
890
            return;
891
        }
892
        if (this.isMultiple) {
893
            if (isArray(modalValue)) {
894
                const selected = [...this.selectionModel.selected];
895
                this.selectionModel.clear();
896
                (modalValue as Array<any>).forEach(itemValue => {
897
                    const option =
898
                        this.options.find(_option => _option.thyValue === itemValue) ||
899
                        selected.find(_option => _option.thyValue === itemValue);
900
                    if (option) {
901
                        this.selectionModel.select(option);
902
                    }
903
                });
904
            }
905
        } else {
906
            const selectedOption = this.options?.find(option => {
907
                return option.thyValue === modalValue;
908
            });
909
            if (selectedOption) {
910
                this.selectionModel.select(selectedOption);
911
            }
912
        }
913
        this.changeDetectorRef.markForCheck();
914
    }
915

916
    private onSelect(option: ThyOptionComponent, isUserInput: boolean) {
917
        const wasSelected = this.selectionModel.isSelected(option);
918

919
        if (option.thyValue == null && !this.isMultiple) {
920
            option.deselect();
921
            this.selectionModel.clear();
922
        } else {
923
            if (wasSelected !== option.selected) {
924
                option.selected ? this.selectionModel.select(option) : this.selectionModel.deselect(option);
925
            }
926

927
            if (isUserInput) {
928
                this.keyManager.setActiveItem(option);
929
            }
930

931
            if (this.isMultiple) {
932
                this.sortValues();
933
                if (isUserInput) {
934
                    this.focus();
935
                }
936
            }
937
        }
938

939
        if (wasSelected !== this.selectionModel.isSelected(option)) {
940
            this.emitModelValueChange();
941
        }
942
        if (!this.isMultiple) {
943
            this.onTouchedFn();
944
        }
945
        this.changeDetectorRef.markForCheck();
946
    }
947

948
    private sortValues() {
949
        if (this.isMultiple) {
950
            const options = this.options.toArray();
951

952
            if (this.thySortComparator) {
953
                this.selectionModel.sort((a, b) => {
954
                    return this.thySortComparator(a, b, options);
955
                });
956
            }
957
        }
958
    }
959

960
    private getOriginRectWidth() {
961
        return this.thyOrigin ? coerceElement(this.thyOrigin).offsetWidth : this.trigger.nativeElement.offsetWidth;
962
    }
963

964
    ngOnDestroy() {
965
        this.destroy$.next();
966
        this.destroy$.complete();
967
    }
968
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc