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

atinc / ngx-tethys / cd83e569-c245-4e44-b187-0a438558e409

14 Dec 2023 10:39AM UTC coverage: 90.331% (-0.004%) from 90.335%
cd83e569-c245-4e44-b187-0a438558e409

push

circleci

web-flow
fix(select): markForCheck when open (#2971)

5332 of 6562 branches covered (0.0%)

Branch coverage included in aggregate %.

13259 of 14019 relevant lines covered (94.58%)

976.36 hits per line

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

90.26
/src/select/custom-select/custom-select.component.ts
1
import {
2
    getFlexiblePositions,
3
    InputBoolean,
4
    InputNumber,
5
    ScrollToService,
6
    TabIndexDisabledControlValueAccessorMixin,
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
    THY_OPTION_PARENT_COMPONENT,
16
    ThyOptionComponent,
17
    ThyOptionSelectionChangeEvent,
18
    ThyScrollDirective,
19
    ThySelectControlComponent,
1✔
20
    ThySelectOptionGroupComponent,
1✔
21
    ThyStopPropagationDirective
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';
428✔
42

43
import { ActiveDescendantKeyManager } from '@angular/cdk/a11y';
44
import { coerceBooleanProperty, coerceElement } from '@angular/cdk/coercion';
113✔
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';
846✔
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,
337✔
68
    Input,
337✔
69
    NgZone,
14✔
70
    OnDestroy,
71
    OnInit,
14!
72
    Optional,
14✔
73
    Output,
14✔
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
    DEFAULT_SELECT_CONFIG,
83
    THY_SELECT_CONFIG,
84
    THY_SELECT_SCROLL_STRATEGY,
85
    ThyDropdownWidthMode,
166✔
86
    ThySelectConfig
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
}
172!
110

851✔
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
        }
166✔
132
    ],
133
    changeDetection: ChangeDetectionStrategy.OnPush,
134
    standalone: true,
235✔
135
    imports: [
235✔
136
        CdkOverlayOrigin,
137
        ThySelectControlComponent,
138
        CdkConnectedOverlay,
182✔
139
        ThyStopPropagationDirective,
182✔
140
        NgClass,
182✔
141
        NgIf,
142
        ThyScrollDirective,
143
        ThyLoadingComponent,
144
        ThyEmptyComponent,
1!
145
        NgTemplateOutlet
1✔
146
    ],
1✔
147
    host: {
148
        '[attr.tabindex]': 'tabIndex',
149
        '(focus)': 'onFocus($event)',
182✔
150
        '(blur)': 'onBlur($event)'
96✔
151
    }
152
})
182✔
153
export class ThySelectCustomComponent
182!
154
    extends TabIndexDisabledControlValueAccessorMixin
182✔
155
    implements ControlValueAccessor, IThyOptionParentComponent, OnInit, AfterContentInit, OnDestroy
156
{
157
    disabled = false;
158

191✔
159
    size: SelectControlSize;
4✔
160

4✔
161
    mode: SelectMode = '';
4✔
162

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

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

167
    scrollTop = 0;
168

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

182✔
171
    defaultOffset = 4;
1✔
172

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

4✔
175
    dropDownMinWidth: number | null = null;
176

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

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

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

172✔
187
    public triggerRectWidth: number;
1✔
188

1✔
189
    public scrollStrategy: ScrollStrategy;
190

172✔
191
    private selectionModelSubscription: Subscription;
172✔
192

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

198
    private config: ThySelectConfig;
199

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

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

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

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

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

218
    keyManager: ActiveDescendantKeyManager<ThyOptionComponent>;
219

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

×
395
    /**
396
     * 多选选中项的展示方式,默认为空,渲染文字模板,传入tag,渲染展示模板,
397
     * @default ''|tag
10✔
398
     */
399
    @Input() thyPreset: string = '';
400

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

172✔
403
    @ViewChild('panel', { read: ElementRef }) panel: ElementRef<HTMLElement>;
2✔
404

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

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

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

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

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

12✔
447
    private isSearching = false;
12✔
448

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

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

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

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

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

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

13✔
514
        return dropdownMinWidth;
13✔
515
    }
516

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

834
            manager.onKeydown(event);
835

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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