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

atinc / ngx-tethys / 3345a388-de9a-417c-aa41-c34807686859

30 Oct 2023 03:26AM UTC coverage: 90.224% (+0.001%) from 90.223%
3345a388-de9a-417c-aa41-c34807686859

Pull #2866

circleci

yxb941006
feat(select): #INFR-10094 add focus input when thyShowSearch & dispatch toggle
Pull Request #2866: feat(select): #INFR-10094 dispatch toggle select panel not close when thyShowSearch is true

5171 of 6391 branches covered (0.0%)

Branch coverage included in aggregate %.

1 of 1 new or added line in 1 file covered. (100.0%)

13047 of 13801 relevant lines covered (94.54%)

972.75 hits per line

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

90.33
/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';
435✔
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';
842✔
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
     */
40✔
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)
239✔
209
        );
210
    }) as Observable<ThyOptionSelectionChangeEvent>;
211

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

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

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

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

55✔
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;
437✔
316

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

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

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

55✔
340
    /**
29✔
341
     * Footer 模板,默认值为空不显示 Footer
29✔
342
     * @type TemplateRef
29✔
343
     */
29✔
344
    @Input()
29✔
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
    /**
78✔
367
     * @private
88!
368
     */
88✔
369
    @ContentChild('selectedDisplay') selectedValueDisplayRef: TemplateRef<any>;
78✔
370

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

71✔
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
     */
174✔
393
    @Input() @InputBoolean() thyBorderless = false;
2✔
394

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

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

399
    @ViewChild(ThySelectControlComponent) selectControl: ThySelectControlComponent;
400

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

86✔
406
    /**
19✔
407
     * @private
18✔
408
     */
409
    @ContentChildren(ThySelectOptionGroupComponent) optionGroups: QueryList<ThySelectOptionGroupComponent>;
410

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

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

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

443
    private isSearching = false;
10✔
444

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

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

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

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

498
    getDropdownMinWidth(): number | null {
499
        const mode = this.thyDropdownWidthMode || this.config.dropdownWidthMode;
174✔
500
        let dropdownMinWidth: number | null = null;
174✔
501

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

174✔
510
        return dropdownMinWidth;
174✔
511
    }
512

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

2✔
541
    public get isHiddenOptions(): boolean {
25✔
542
        return this.options.toArray().every(option => option.hidden);
22✔
543
    }
544

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

676
    public toggle(event: MouseEvent): void {
1✔
677
        this.panelOpen ? (!this.thyShowSearch ? this.close() : this.elementRef.nativeElement.querySelector('input').focus()) : this.open();
678
    }
679

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

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

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

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

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

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

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

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

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

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

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

822
            manager.onKeydown(event);
823

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

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

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

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

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

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

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

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

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

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

929
            if (isUserInput) {
930
                this.keyManager.setActiveItem(option);
931
            }
932

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

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

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

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

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

966
    ngOnDestroy() {
967
        this.destroy$.next();
968
        this.destroy$.complete();
969
    }
970
}
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