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

atinc / ngx-tethys / d9ae709b-3c27-4b69-b125-b8b80b54f90b

pending completion
d9ae709b-3c27-4b69-b125-b8b80b54f90b

Pull #2757

circleci

mengshuicmq
fix: fix code review
Pull Request #2757: feat(color-picker): color-picker support disabled (#INFR-8645)

98 of 6315 branches covered (1.55%)

Branch coverage included in aggregate %.

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

2392 of 13661 relevant lines covered (17.51%)

83.12 hits per line

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

3.65
/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,
×
34
    FunctionProp,
×
35
    hasModifierKey,
×
36
    helpers,
×
37
    HOME,
38
    isArray,
39
    isFunction,
×
40
    LEFT_ARROW,
41
    RIGHT_ARROW,
42
    SPACE,
×
43
    UP_ARROW
44
} from 'ngx-tethys/util';
45
import { defer, merge, Observable, Subject, Subscription, timer } from 'rxjs';
×
46
import { filter, map, startWith, switchMap, take, takeUntil } from 'rxjs/operators';
47

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

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

×
94
export type SelectMode = 'multiple' | '';
×
95

×
96
export type ThyCustomSelectTriggerType = 'click' | 'hover';
×
97

×
98
export const SELECT_PANEL_MAX_HEIGHT = 300;
×
99

×
100
export const SELECT_OPTION_MAX_HEIGHT = 40;
×
101

×
102
export const SELECT_OPTION_GROUP_MAX_HEIGHT = 30;
×
103

×
104
export const SELECT_PANEL_PADDING_TOP = 10;
105

106
export const THY_SELECT_PANEL_MIN_WIDTH = 200;
107

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

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

×
120
const noop = () => {};
×
121

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

×
168
    size: SelectControlSize;
×
169

×
170
    mode: SelectMode = '';
×
171

172
    emptyStateText = '暂无可选项';
×
173

×
174
    emptySearchMessageText = '暂无可选项';
175

176
    scrollTop = 0;
×
177

178
    modalValue: any = null;
×
179

180
    defaultOffset = 4;
181

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

×
184
    dropDownMinWidth: number | null = null;
×
185

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

192
    public dropDownPositions: ConnectionPositionPair[];
193

194
    public selectionModel: SelectionModel<ThyOptionComponent>;
×
195

×
196
    public triggerRectWidth: number;
197

198
    public scrollStrategy: ScrollStrategy;
199

×
200
    private selectionModelSubscription: Subscription;
×
201

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

207
    private config: ThySelectConfig;
208

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

211
    readonly optionSelectionChanges: Observable<ThyOptionSelectionChangeEvent> = defer(() => {
×
212
        if (this.options) {
×
213
            return merge(...this.options.map(option => option.selectionChange));
×
214
        }
×
215
        return this.ngZone.onStable.asObservable().pipe(
×
216
            take(1),
217
            switchMap(() => this.optionSelectionChanges)
218
        );
×
219
    }) as Observable<ThyOptionSelectionChangeEvent>;
×
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

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

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

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

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

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

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

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

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

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

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

×
286
    get thyMode(): SelectMode {
287
        return this.mode;
×
288
    }
×
289

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

400
    @ViewChild('panel', { read: ElementRef }) panel: ElementRef<HTMLElement>;
×
401

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

×
407
    /**
×
408
     * @private
409
     */
410
    @ContentChildren(ThySelectOptionGroupComponent) optionGroups: QueryList<ThySelectOptionGroupComponent>;
×
411

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

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

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

×
444
    private isSearching = false;
445

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

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

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

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

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

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

×
511
        return dropdownMinWidth;
512
    }
513

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

×
542
    public get isHiddenOptions(): boolean {
×
543
        return this.options.toArray().every(option => option.hidden);
544
    }
545

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

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

×
568
    public onOptionsScrolled(elementRef: ElementRef) {
×
569
        const scroll = elementRef.nativeElement.scrollTop,
570
            height = elementRef.nativeElement.clientHeight,
×
571
            scrollHeight = elementRef.nativeElement.scrollHeight;
×
572

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

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

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

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

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

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

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

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

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

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

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

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

677
    public toggle(event: MouseEvent): void {
678
        this.panelOpen ? this.close() : this.open();
679
    }
1✔
680

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

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

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

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

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

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

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

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

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

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

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

823
            manager.onKeydown(event);
824

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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