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

atinc / ngx-tethys / 477fa07e-de00-4a10-bc6c-3cfbe5bce129

pending completion
477fa07e-de00-4a10-bc6c-3cfbe5bce129

Pull #2767

circleci

yxb941006
build: modify by code review
Pull Request #2767: feat(select): #INFR-9135 add thyBorderless at custom-select

5104 of 6317 branches covered (80.8%)

Branch coverage included in aggregate %.

7 of 7 new or added lines in 2 files covered. (100.0%)

12901 of 13654 relevant lines covered (94.49%)

974.94 hits per line

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

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

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

86
import {
165✔
87
    THY_SELECT_SCROLL_STRATEGY,
165✔
88
    THY_SELECT_CONFIG,
165✔
89
    ThySelectConfig,
165✔
90
    ThyDropdownWidthMode,
165✔
91
    DEFAULT_SELECT_CONFIG
165✔
92
} from '../select.config';
165✔
93

165✔
94
export type SelectMode = 'multiple' | '';
165✔
95

165✔
96
export type ThyCustomSelectTriggerType = 'click' | 'hover';
165✔
97

165✔
98
export const SELECT_PANEL_MAX_HEIGHT = 300;
165✔
99

165✔
100
export const SELECT_OPTION_MAX_HEIGHT = 40;
165✔
101

165✔
102
export const SELECT_OPTION_GROUP_MAX_HEIGHT = 30;
165✔
103

165✔
104
export const SELECT_PANEL_PADDING_TOP = 10;
105

106
export const THY_SELECT_PANEL_MIN_WIDTH = 200;
107

165✔
108
export interface OptionValue {
165✔
109
    thyLabelText?: string;
165✔
110
    thyValue?: string;
173!
111
    thyDisabled?: boolean;
848✔
112
    thyShowOptionCustom?: boolean;
113
    thySearchKey?: string;
×
114
}
115

165✔
116
const _MixinBase: Constructor<ThyHasTabIndex> & Constructor<ThyCanDisable> & typeof AbstractControlValueAccessor = mixinTabIndex(
165✔
117
    mixinDisabled(AbstractControlValueAccessor)
165✔
118
);
165✔
119

165✔
120
const noop = () => {};
165✔
121

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

168
    size: SelectControlSize;
181✔
169

181✔
170
    mode: SelectMode = '';
181✔
171

1✔
172
    emptyStateText = '暂无可选项';
173

180✔
174
    emptySearchMessageText = '暂无可选项';
4✔
175

176
    scrollTop = 0;
177

176✔
178
    modalValue: any = null;
179

181✔
180
    defaultOffset = 4;
181

182
    dropDownClass: { [key: string]: boolean };
164✔
183

173✔
184
    dropDownMinWidth: number | null = null;
173✔
185

173✔
186
    /**
173✔
187
     * 设置下拉框的最小宽度,默认值 `match-select`,表示与输入框的宽度一致;`min-width` 表示最小宽度为200px;支持自定义最小宽度,比如传 `{minWidth: 150}` 表示最小宽度为150px
1✔
188
     * @default match-select
1✔
189
     */
190
    @Input() thyDropdownWidthMode: ThyDropdownWidthMode;
173✔
191

173✔
192
    public dropDownPositions: ConnectionPositionPair[];
193

194
    public selectionModel: SelectionModel<ThyOptionComponent>;
195

173✔
196
    public triggerRectWidth: number;
40✔
197

198
    public scrollStrategy: ScrollStrategy;
199

200
    private selectionModelSubscription: Subscription;
164✔
201

1✔
202
    /**
1✔
203
     * 手动聚焦中的标识
1✔
204
     */
1✔
205
    private manualFocusing = false;
206

207
    private config: ThySelectConfig;
208

209
    private readonly destroy$ = new Subject<void>();
229✔
210

211
    readonly optionSelectionChanges: Observable<ThyOptionSelectionChangeEvent> = defer(() => {
212
        if (this.options) {
74✔
213
            return merge(...this.options.map(option => option.selectionChange));
69✔
214
        }
58✔
215
        return this.ngZone.onStable.asObservable().pipe(
56✔
216
            take(1),
56✔
217
            switchMap(() => this.optionSelectionChanges)
218
        );
219
    }) as Observable<ThyOptionSelectionChangeEvent>;
2!
220

×
221
    @ViewChild(CdkConnectedOverlay, { static: true }) cdkConnectedOverlay: CdkConnectedOverlay;
×
222

223
    @HostBinding('class.thy-select-custom') isSelectCustom = true;
224

225
    @HostBinding('class.thy-select') isSelect = true;
226

227
    keyManager: ActiveDescendantKeyManager<ThyOptionComponent>;
228

1!
229
    @HostBinding('class.menu-is-opened')
1✔
230
    panelOpen = false;
231

232
    /**
233
     * 搜索时回调
1✔
234
     */
1!
235
    @Output() thyOnSearch: EventEmitter<string> = new EventEmitter<string>();
1!
236

1✔
237
    /**
1✔
238
     * 下拉菜单滚动到底部事件,可以用这个事件实现滚动加载
239
     */
240
    @Output() thyOnScrollToBottom: EventEmitter<void> = new EventEmitter<void>();
241

242
    /**
243
     * 下拉菜单展开和折叠状态事件
11✔
244
     */
11✔
245
    @Output() thyOnExpandStatusChange: EventEmitter<boolean> = new EventEmitter<boolean>();
2✔
246

2✔
247
    /**
248
     * 下拉列表是否显示搜索框
249
     * @default false
9✔
250
     */
9✔
251
    @Input() @InputBoolean() thyShowSearch: boolean;
52✔
252

12✔
253
    /**
254
     * 选择框默认文字
255
     */
40✔
256
    @Input() thyPlaceHolder: string;
257

258
    /**
9✔
259
     * 是否使用服务端搜索,当为 true 时,将不再在前端进行过滤
9✔
260
     * @default false
261
     */
262
    @Input() @InputBoolean() thyServerSearch: boolean;
263

264
    /**
2✔
265
     * 异步加载 loading 状态,false 表示加载中,true 表示加载完成
1✔
266
     */
267
    @Input() @InputBoolean() thyLoadState = true;
1✔
268

269
    /**
270
     * 是否自动设置选项第一条为高亮状态
271
     */
30✔
272
    @Input() @InputBoolean() thyAutoActiveFirstItem = true;
273

3✔
274
    /**
3✔
275
     * 下拉选择模式
276
     * @type 'multiple' | ''
30✔
277
     */
278
    @Input()
279
    set thyMode(value: SelectMode) {
54✔
280
        this.mode = value;
54✔
281
        this.instanceSelectionModel();
54✔
282
        this.getPositions();
283
        this.setDropDownClass();
284
    }
4✔
285

4✔
286
    get thyMode(): SelectMode {
1✔
287
        return this.mode;
288
    }
18✔
289

1✔
290
    /**
291
     * 操作图标类型
1✔
292
     * @type primary | success | danger | warning
293
     * @default primary
294
     */
2✔
295
    @Input()
296
    get thySize(): SelectControlSize {
297
        return this.size;
298
    }
4✔
299
    set thySize(value: SelectControlSize) {
3✔
300
        this.size = value;
301
    }
4✔
302

1✔
303
    /**
304
     * 数据为空时显示的提示文字
3✔
305
     */
3✔
306
    @Input()
3✔
307
    set thyEmptyStateText(value: string) {
308
        this.emptyStateText = value;
309
    }
49✔
310

49✔
311
    /**
36✔
312
     * 搜索结果为空时显示的提示文字
313
     */
314
    @Input()
315
    set thyEmptySearchMessageText(value: string) {
316
        this.emptySearchMessageText = value;
426✔
317
    }
318

319
    /**
1,818✔
320
     * 滚动加载是否可用,只能当这个参数可以,下面的thyOnScrollToBottom事件才会触发
321
     */
322
    @Input()
74✔
323
    @InputBoolean()
324
    thyEnableScrollLoad = false;
325

1✔
326
    /**
327
     * 单选( thyMode="" 或者不设置)时,选择框支持清除
328
     */
82✔
329
    @Input() @InputBoolean() thyAllowClear = false;
330

331
    /**
77✔
332
     * 是否禁用
2✔
333
     * @default false
334
     */
75✔
335
    @Input()
75✔
336
    @InputBoolean()
75✔
337
    get thyDisabled(): boolean {
75✔
338
        return this.disabled;
339
    }
340
    set thyDisabled(value: boolean) {
53✔
341
        this.disabled = coerceBooleanProperty(value);
28✔
342
    }
28✔
343

28✔
344
    /**
28✔
345
     * 排序比较函数
28✔
346
     */
347
    @Input() thySortComparator: (a: ThyOptionComponent, b: ThyOptionComponent, options: ThyOptionComponent[]) => number;
348

349
    /**
40✔
350
     * Footer 模板,默认值为空不显示 Footer
40✔
351
     * @type TemplateRef
38✔
352
     */
353
    @Input()
40✔
354
    thyFooterTemplate: TemplateRef<any>;
18✔
355

356
    /**
357
     * 弹出位置
22✔
358
     * @type ThyPlacement
2✔
359
     */
360
    @Input()
361
    thyPlacement: ThyPlacement;
20✔
362

363
    /**
364
     * 自定义 Overlay Origin
40✔
365
     */
40✔
366
    @Input()
367
    thyOrigin: ElementRef | HTMLElement;
75✔
368

85!
369
    /**
85✔
370
     * 自定义 Footer 模板容器 class
75✔
371
     */
5✔
372
    @Input()
373
    thyFooterClass = 'thy-custom-select-footer';
70✔
374

69✔
375
    /**
1✔
376
     * @private
377
     */
68✔
378
    @ContentChild('selectedDisplay') selectedValueDisplayRef: TemplateRef<any>;
379

380
    /**
1✔
381
     * 初始化时,是否展开面板
382
     * @default false
383
     */
384
    @Input() @InputBoolean() thyAutoExpand: boolean;
10!
385

×
386
    /**
387
     * 是否弹出透明遮罩,如果显示遮罩则会阻止滚动区域滚动
388
     */
10✔
389
    @Input()
390
    @InputBoolean()
391
    thyHasBackdrop = false;
392

393
    /**
173✔
394
     * 设置多选时最大显示的标签数量,0 表示不限制
2✔
395
     */
396
    @Input() @InputNumber() thyMaxTagCount = 0;
173✔
397

398
    /**
399
     * 是否隐藏选择框边框
400
     * @default false
401
     */
173✔
402
    @Input() @InputBoolean() thyBorderless = false;
×
403

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

173✔
406
    @ViewChild('panel', { read: ElementRef }) panel: ElementRef<HTMLElement>;
83✔
407

19✔
408
    /**
18✔
409
     * @private
410
     */
411
    @ContentChildren(ThyOptionComponent, { descendants: true }) options: QueryList<ThyOptionComponent>;
64✔
412

5✔
413
    /**
414
     * @private
415
     */
416
    @ContentChildren(ThySelectOptionGroupComponent) optionGroups: QueryList<ThySelectOptionGroupComponent>;
417

4✔
418
    @HostListener('keydown', ['$event'])
4✔
419
    handleKeydown(event: KeyboardEvent): void {
4✔
420
        if (!this.disabled) {
4✔
421
            if (event.keyCode === ENTER) {
422
                event.stopPropagation();
4!
423
            }
1✔
424
            this.panelOpen ? this.handleOpenKeydown(event) : this.handleClosedKeydown(event);
1✔
425
        }
426
    }
3!
427

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

2✔
442
    private buildScrollStrategy() {
2✔
443
        if (this.scrollStrategyFactory && isFunction(this.scrollStrategyFactory)) {
444
            this.scrollStrategy = this.scrollStrategyFactory();
10✔
445
        } else {
446
            this.scrollStrategy = this.overlay.scrollStrategies.reposition();
1✔
447
        }
1✔
448
    }
449

9✔
450
    private isSearching = false;
3✔
451

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

468
    writeValue(value: any): void {
6!
469
        this.modalValue = value;
×
470
        this.setSelectionByModelValue(this.modalValue);
471
    }
6✔
472

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

171✔
490
        if (isPlatformBrowser(this.platformId)) {
171✔
491
            this.thyClickDispatcher
7✔
492
                .clicked(0)
7✔
493
                .pipe(takeUntil(this.destroy$))
494
                .subscribe(event => {
171✔
495
                    if (!this.elementRef.nativeElement.contains(event.target) && this.panelOpen) {
91✔
496
                        this.ngZone.run(() => {
91✔
497
                            this.close();
498
                            this.changeDetectorRef.markForCheck();
499
                        });
500
                    }
173✔
501
                });
173✔
502
        }
93✔
503
    }
93✔
504

13✔
505
    getDropdownMinWidth(): number | null {
13✔
506
        const mode = this.thyDropdownWidthMode || this.config.dropdownWidthMode;
507
        let dropdownMinWidth: number | null = null;
508

509
        if ((mode as { minWidth: number })?.minWidth) {
510
            dropdownMinWidth = (mode as { minWidth: number }).minWidth;
173✔
511
        } else if (mode === 'min-width') {
173✔
512
            dropdownMinWidth = THY_SELECT_PANEL_MIN_WIDTH;
513
        } else {
514
            dropdownMinWidth = null;
515
        }
257✔
516

257✔
517
        return dropdownMinWidth;
39✔
518
    }
519

520
    ngAfterContentInit() {
218✔
521
        this.optionsChanges$.pipe(startWith(null), takeUntil(this.destroy$)).subscribe(data => {
522
            this.resetOptions();
257✔
523
            this.initializeSelection();
524
            this.initKeyManager();
525
            if (this.isSearching) {
526
                this.highlightCorrectOption(false);
527
                this.isSearching = false;
528
            }
408✔
529
            this.changeDetectorRef.markForCheck();
231✔
530
            this.ngZone.onStable
1✔
531
                .asObservable()
1✔
532
                .pipe(take(1))
533
                .subscribe(() => {
231✔
534
                    if (this.cdkConnectedOverlay && this.cdkConnectedOverlay.overlayRef) {
535
                        this.cdkConnectedOverlay.overlayRef.updatePosition();
177✔
536
                    }
24!
537
                });
24✔
538
        });
24✔
539
        if (this.thyAutoExpand) {
24✔
540
            timer(0).subscribe(() => {
185✔
541
                this.changeDetectorRef.markForCheck();
2✔
542
                this.open();
25✔
543
                this.focus();
22✔
544
            });
545
        }
546
    }
547

548
    public get isHiddenOptions(): boolean {
549
        return this.options.toArray().every(option => option.hidden);
153✔
550
    }
165✔
551

552
    public onAttached(): void {
153✔
553
        this.cdkConnectedOverlay.positionChange.pipe(take(1)).subscribe(() => {
125✔
554
            if (this.panel) {
555
                if (this.keyManager.activeItem) {
556
                    ScrollToService.scrollToElement(this.keyManager.activeItem.element.nativeElement, this.panel.nativeElement);
177✔
557
                    this.changeDetectorRef.detectChanges();
558
                } else {
559
                    if (!this.empty) {
94✔
560
                        ScrollToService.scrollToElement(this.selectionModel.selected[0].element.nativeElement, this.panel.nativeElement);
94!
561
                        this.changeDetectorRef.detectChanges();
×
562
                    }
×
563
                }
564
            }
565
        });
94✔
566
    }
37✔
567

568
    public dropDownMouseMove(event: MouseEvent) {
94✔
569
        if (this.keyManager.activeItem) {
32✔
570
            this.keyManager.setActiveItem(-1);
571
        }
94✔
572
    }
49✔
573

49✔
574
    public onOptionsScrolled(elementRef: ElementRef) {
12✔
575
        const scroll = elementRef.nativeElement.scrollTop,
576
            height = elementRef.nativeElement.clientHeight,
577
            scrollHeight = elementRef.nativeElement.scrollHeight;
578

94✔
579
        if (scroll + height + 10 >= scrollHeight) {
37✔
580
            if (this.thyOnScrollToBottom.observers.length > 0) {
581
                this.ngZone.run(() => {
94✔
582
                    this.thyOnScrollToBottom.emit();
45✔
583
                });
584
            }
94✔
585
        }
586
    }
587

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

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

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

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

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

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

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

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

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

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

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

683
    public toggle(event: MouseEvent): void {
684
        this.panelOpen ? this.close() : this.open();
685
    }
1✔
686

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

697
    public close(): void {
1✔
698
        if (this.panelOpen) {
699
            this.panelOpen = false;
700
            this.thyOnExpandStatusChange.emit(this.panelOpen);
701
            this.focus();
702
            this.changeDetectorRef.markForCheck();
703
            this.onTouchedFn();
704
        }
705
    }
706

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

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

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

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

774
    private handleClosedKeydown(event: KeyboardEvent): void {
775
        const keyCode = event.keyCode;
776
        const isArrowKey = keyCode === DOWN_ARROW || keyCode === UP_ARROW || keyCode === LEFT_ARROW || keyCode === RIGHT_ARROW;
777
        const isOpenKey = keyCode === ENTER || keyCode === SPACE;
778
        const manager = this.keyManager;
779

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

794
    private handleOpenKeydown(event: KeyboardEvent): void {
795
        const keyCode = event.keyCode;
796
        const isArrowKey = keyCode === DOWN_ARROW || keyCode === UP_ARROW;
797
        const manager = this.keyManager;
798

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

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

829
            manager.onKeydown(event);
830

831
            if (
832
                this.isMultiple &&
833
                isArrowKey &&
834
                event.shiftKey &&
835
                manager.activeItem &&
836
                manager.activeItemIndex !== previouslyFocusedIndex
837
            ) {
838
                manager.activeItem.selectViaInteraction();
839
            }
840
        }
841
    }
842

843
    private getPositions() {
844
        this.dropDownPositions = getFlexiblePositions(this.thyPlacement || this.config.placement, this.defaultOffset);
845
    }
846

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

862
    private resetOptions() {
863
        const changedOrDestroyed$ = merge(this.optionsChanges$, this.destroy$);
864

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

874
    private initializeSelection() {
875
        Promise.resolve().then(() => {
876
            this.setSelectionByModelValue(this.modalValue);
877
        });
878
    }
879

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

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

925
    private onSelect(option: ThyOptionComponent, isUserInput: boolean) {
926
        const wasSelected = this.selectionModel.isSelected(option);
927

928
        if (option.thyValue == null && !this.isMultiple) {
929
            option.deselect();
930
            this.selectionModel.clear();
931
        } else {
932
            if (wasSelected !== option.selected) {
933
                option.selected ? this.selectionModel.select(option) : this.selectionModel.deselect(option);
934
            }
935

936
            if (isUserInput) {
937
                this.keyManager.setActiveItem(option);
938
            }
939

940
            if (this.isMultiple) {
941
                this.sortValues();
942
                if (isUserInput) {
943
                    this.focus();
944
                }
945
            }
946
        }
947

948
        if (wasSelected !== this.selectionModel.isSelected(option)) {
949
            this.emitModelValueChange();
950
        }
951
        if (!this.isMultiple) {
952
            this.onTouchedFn();
953
        }
954
        this.changeDetectorRef.markForCheck();
955
    }
956

957
    private sortValues() {
958
        if (this.isMultiple) {
959
            const options = this.options.toArray();
960

961
            if (this.thySortComparator) {
962
                this.selectionModel.sort((a, b) => {
963
                    return this.thySortComparator(a, b, options);
964
                });
965
            }
966
        }
967
    }
968

969
    private getOriginRectWidth() {
970
        return this.thyOrigin ? coerceElement(this.thyOrigin).offsetWidth : this.trigger.nativeElement.offsetWidth;
971
    }
972

973
    ngOnDestroy() {
974
        this.destroy$.next();
975
        this.destroy$.complete();
976
    }
977
}
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