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

atinc / ngx-tethys / 8a6ba229-c82f-4a21-a1ed-95461f2ad66c

04 Sep 2023 08:37AM UTC coverage: 90.196% (-0.004%) from 90.2%
8a6ba229-c82f-4a21-a1ed-95461f2ad66c

Pull #2829

circleci

cmm-va
fix: delete f
Pull Request #2829: fix: add tabIndex

5164 of 6386 branches covered (0.0%)

Branch coverage included in aggregate %.

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

13024 of 13779 relevant lines covered (94.52%)

971.69 hits per line

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

90.29
/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';
423✔
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';
824✔
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,
335✔
68
    Input,
335✔
69
    NgZone,
17✔
70
    OnDestroy,
71
    OnInit,
17!
72
    Optional,
17✔
73
    Output,
17✔
74
    PLATFORM_ID,
75
    QueryList,
76
    TemplateRef,
77
    ViewChild
164!
78
} from '@angular/core';
164✔
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,
164✔
86
    DEFAULT_SELECT_CONFIG
164✔
87
} from '../select.config';
164✔
88

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

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

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

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

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

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

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

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

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

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

4✔
159
    size: SelectControlSize;
4✔
160

4✔
161
    mode: SelectMode = '';
162

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

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

167
    scrollTop = 0;
180✔
168

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

1✔
171
    defaultOffset = 4;
172

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

175
    dropDownMinWidth: number | null = null;
176

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

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

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

1✔
187
    public triggerRectWidth: number;
1✔
188

189
    public scrollStrategy: ScrollStrategy;
172✔
190

172✔
191
    private selectionModelSubscription: Subscription;
192

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

198
    private config: ThySelectConfig;
199

163✔
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)
229✔
209
        );
210
    }) as Observable<ThyOptionSelectionChangeEvent>;
211

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

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

56✔
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) {
30✔
271
        this.mode = value;
272
        this.instanceSelectionModel();
3✔
273
        this.getPositions();
3✔
274
        this.setDropDownClass();
275
    }
30✔
276

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

404
    /**
172✔
405
     * @private
83✔
406
     */
19✔
407
    @ContentChildren(ThySelectOptionGroupComponent) optionGroups: QueryList<ThySelectOptionGroupComponent>;
18✔
408

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

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

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

2✔
441
    private isSearching = false;
2✔
442

443
    constructor(
10✔
444
        private ngZone: NgZone,
445
        private elementRef: ElementRef,
1✔
446
        private viewportRuler: ViewportRuler,
1✔
447
        private changeDetectorRef: ChangeDetectorRef,
448
        private overlay: Overlay,
9✔
449
        private thyClickDispatcher: ThyClickDispatcher,
3✔
450
        @Inject(PLATFORM_ID) private platformId: string,
3!
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 };
3✔
456
        this.buildScrollStrategy();
457
    }
6!
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
6!
468
            .change()
×
469
            .pipe(takeUntil(this.destroy$))
470
            .subscribe(() => {
6✔
471
                if (this.panelOpen) {
6✔
472
                    this.triggerRectWidth = this.getOriginRectWidth();
6!
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
256✔
483
                .clicked(0)
484
                .pipe(takeUntil(this.destroy$))
485
                .subscribe(event => {
170✔
486
                    if (!this.elementRef.nativeElement.contains(event.target) && this.panelOpen) {
7✔
487
                        this.ngZone.run(() => {
488
                            this.close();
170✔
489
                            this.changeDetectorRef.markForCheck();
170✔
490
                        });
7✔
491
                    }
7✔
492
                });
493
        }
170✔
494
    }
91✔
495

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

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

508
        return dropdownMinWidth;
509
    }
172✔
510

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

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

22✔
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();
151✔
549
                } else {
163✔
550
                    if (!this.empty) {
551
                        ScrollToService.scrollToElement(this.selectionModel.selected[0].element.nativeElement, this.panel.nativeElement);
151✔
552
                        this.changeDetectorRef.detectChanges();
123✔
553
                    }
554
                }
555
            }
175✔
556
        });
557
    }
558

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

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

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

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

598
    onBlur(event?: FocusEvent) {
599
        // Tab 聚焦后自动聚焦到 input 输入框,此分支下直接返回,无需触发 onTouchedFn
164✔
600
        if (elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-select-dropdown', 'thy-custom-select'])) {
164✔
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 {
149✔
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

© 2025 Coveralls, Inc