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

atinc / ngx-tethys / 233ed64a-b8f5-4cf5-ac5f-a2f2763ca062

22 Nov 2024 07:31AM UTC coverage: 90.355% (+0.004%) from 90.351%
233ed64a-b8f5-4cf5-ac5f-a2f2763ca062

Pull #3272

circleci

minlovehua
feat: empty icon use 'preset-light' in dark theme #TINFR-975
Pull Request #3272: feat: panel empty icon use 'preset-light' in dark theme #TINFR-975

5548 of 6791 branches covered (81.7%)

Branch coverage included in aggregate %.

5 of 5 new or added lines in 3 files covered. (100.0%)

35 existing lines in 4 files now uncovered.

13263 of 14028 relevant lines covered (94.55%)

992.53 hits per line

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

92.2
/src/select/custom-select/custom-select.component.ts
1
import {
2
    getFlexiblePositions,
3
    injectPanelEmptyIcon,
4
    scaleMotion,
5
    scaleXMotion,
6
    scaleYMotion,
7
    ScrollToService,
8
    TabIndexDisabledControlValueAccessorMixin,
9
    ThyClickDispatcher,
10
    ThyPlacement
11
} from 'ngx-tethys/core';
12
import { ThyEmpty } from 'ngx-tethys/empty';
13
import { ThyLoading } from 'ngx-tethys/loading';
14
import {
15
    IThyOptionParentComponent,
16
    SelectControlSize,
17
    THY_OPTION_PARENT_COMPONENT,
18
    ThyOption,
19
    ThyOptionsContainer,
1✔
20
    ThyOptionSelectionChangeEvent,
1✔
21
    ThyScrollDirective,
1✔
22
    ThySelectControl,
1✔
23
    ThySelectOptionGroup,
1✔
24
    ThyStopPropagationDirective
1✔
25
} from 'ngx-tethys/shared';
26
import {
27
    A,
28
    coerceBooleanProperty,
29
    DOWN_ARROW,
30
    elementMatchClosest,
1✔
31
    END,
32
    ENTER,
78✔
33
    FunctionProp,
78✔
34
    hasModifierKey,
78✔
35
    helpers,
78✔
36
    HOME,
37
    isArray,
38
    isFunction,
1✔
39
    LEFT_ARROW,
40
    RIGHT_ARROW,
41
    SPACE,
447✔
42
    UP_ARROW
43
} from 'ngx-tethys/util';
44
import { defer, merge, Observable, Subject, Subscription, timer } from 'rxjs';
114✔
45
import { distinctUntilChanged, filter, map, startWith, switchMap, take, takeUntil } from 'rxjs/operators';
46

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

16✔
81
import {
16✔
82
    DEFAULT_SELECT_CONFIG,
16✔
83
    THY_SELECT_CONFIG,
84
    THY_SELECT_SCROLL_STRATEGY,
85
    ThyDropdownWidthMode,
86
    ThySelectConfig
172!
87
} from '../select.config';
172✔
88
import { injectLocale, ThySelectLocale } from 'ngx-tethys/i18n';
89

UNCOV
90
export type SelectMode = 'multiple' | '';
×
91

92
export type ThySelectTriggerType = 'click' | 'hover';
93

94
export const SELECT_PANEL_MAX_HEIGHT = 300;
1,520✔
95

96
export const SELECT_OPTION_MAX_HEIGHT = 40;
97

172✔
98
export const SELECT_OPTION_GROUP_MAX_HEIGHT = 30;
172✔
99

172✔
100
export const SELECT_PANEL_PADDING_TOP = 10;
172✔
101

172✔
102
export const THY_SELECT_PANEL_MIN_WIDTH = 200;
172✔
103

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

172✔
112
export interface ThySelectOptionModel {
172✔
113
    value?: string | number;
172✔
114
    disabled?: boolean;
172✔
115
    label?: string;
172✔
116
    icon?: string;
117
    groupLabel?: string;
118
}
119

172✔
120
interface ThyOptionGroupModel extends ThySelectOptionModel {
172✔
121
    children?: ThySelectOptionModel[];
172✔
122
}
179!
123

876✔
124
const noop = () => {};
125

×
126
/**
127
 * 下拉选择组件
172✔
128
 * @name thy-select,thy-custom-select
172✔
129
 * @order 10
172✔
130
 */
172✔
131
@Component({
172✔
132
    selector: 'thy-select,thy-custom-select',
172✔
133
    templateUrl: './custom-select.component.html',
172✔
134
    exportAs: 'thySelect',
172✔
135
    providers: [
172✔
136
        {
172✔
137
            provide: THY_OPTION_PARENT_COMPONENT,
172✔
138
            useExisting: ThySelect
172✔
139
        },
172✔
140
        {
172✔
141
            provide: NG_VALUE_ACCESSOR,
172✔
142
            useExisting: forwardRef(() => ThySelect),
172✔
143
            multi: true
172✔
144
        }
172✔
145
    ],
172✔
146
    changeDetection: ChangeDetectionStrategy.OnPush,
172✔
147
    standalone: true,
172✔
148
    imports: [
172✔
149
        CdkOverlayOrigin,
172✔
150
        ThySelectControl,
151
        CdkConnectedOverlay,
152
        ThyStopPropagationDirective,
247✔
153
        NgClass,
247✔
154
        ThyScrollDirective,
155
        ThyLoading,
156
        ThyEmpty,
188✔
157
        ThyOptionsContainer,
188✔
158
        ThyOption,
188✔
159
        ThySelectOptionGroup,
100✔
160
        NgTemplateOutlet
161
    ],
188✔
162
    host: {
188!
163
        '[attr.tabindex]': 'tabIndex',
188✔
164
        '(focus)': 'onFocus($event)',
165
        '(blur)': 'onBlur($event)'
166
    },
167
    animations: [scaleXMotion, scaleYMotion, scaleMotion]
198✔
168
})
4✔
169
export class ThySelect
4✔
170
    extends TabIndexDisabledControlValueAccessorMixin
4✔
171
    implements ControlValueAccessor, IThyOptionParentComponent, OnInit, AfterViewInit, AfterContentInit, OnDestroy
172
{
173
    private ngZone = inject(NgZone);
174
    private elementRef = inject(ElementRef);
175
    private changeDetectorRef = inject(ChangeDetectorRef);
176
    private overlay = inject(Overlay);
177
    private thyClickDispatcher = inject(ThyClickDispatcher);
1✔
178
    private platformId = inject(PLATFORM_ID);
3✔
179
    private locale: Signal<ThySelectLocale> = injectLocale('select');
1✔
180
    scrollStrategyFactory = inject<FunctionProp<ScrollStrategy>>(THY_SELECT_SCROLL_STRATEGY, { optional: true })!;
1✔
181
    selectConfig = inject(THY_SELECT_CONFIG, { optional: true })!;
3✔
182
    emptyIcon: Signal<string> = injectPanelEmptyIcon();
1✔
183

184
    disabled = false;
185

186
    size: SelectControlSize;
1✔
187

188
    mode: SelectMode = '';
1✔
189

3✔
190
    emptyStateText = this.locale().empty;
2✔
191

2✔
192
    emptySearchMessageText = this.locale().empty;
1✔
193

1✔
194
    scrollTop = 0;
195

196
    modalValue: any = null;
197

1✔
198
    defaultOffset = 4;
199

200
    dropDownClass: { [key: string]: boolean };
1✔
201

202
    dropDownMinWidth: number | null = null;
203

12✔
204
    /**
1✔
205
     * 设置下拉框的最小宽度,默认值 `match-select`,表示与输入框的宽度一致;`min-width` 表示最小宽度为200px;支持自定义最小宽度,比如传 `{minWidth: 150}` 表示最小宽度为150px
206
     * @default match-select
207
     */
3✔
208
    @Input() thyDropdownWidthMode: ThyDropdownWidthMode;
209

210
    public dropDownPositions: ConnectionPositionPair[];
211

188✔
212
    public selectionModel: SelectionModel<ThyOption>;
188✔
213

188✔
214
    public triggerRectWidth: number;
1✔
215

216
    public scrollStrategy: ScrollStrategy;
187✔
217

4✔
218
    private resizeSubscription: Subscription;
219

220
    private selectionModelSubscription: Subscription;
183✔
221

222
    /**
188✔
223
     * 手动聚焦中的标识
224
     */
225
    private manualFocusing = false;
171✔
226

3✔
227
    private config: ThySelectConfig;
228

229
    private readonly destroy$ = new Subject<void>();
230

171✔
231
    readonly optionSelectionChanges: Observable<ThyOptionSelectionChangeEvent> = defer(() => {
168✔
232
        if (this.options) {
233
            return merge(...this.options.map(option => option.selectionChange));
234
        }
235
        return this.ngZone.onStable.asObservable().pipe(
171✔
236
            take(1),
179✔
237
            switchMap(() => this.optionSelectionChanges)
179✔
238
        );
179✔
239
    }) as Observable<ThyOptionSelectionChangeEvent>;
179✔
240

1✔
241
    @ViewChild(CdkConnectedOverlay, { static: true }) cdkConnectedOverlay: CdkConnectedOverlay;
1✔
242

243
    @HostBinding('class.thy-select-custom') isSelectCustom = true;
179✔
244

179✔
245
    @HostBinding('class.thy-select') isSelect = true;
246

247
    keyManager: ActiveDescendantKeyManager<ThyOption>;
248

179✔
249
    @HostBinding('class.menu-is-opened')
42✔
250
    panelOpen = false;
251

252
    /**
253
     * 搜索时回调
171✔
254
     */
1✔
255
    @Output() thyOnSearch: EventEmitter<string> = new EventEmitter<string>();
1✔
256

1✔
257
    /**
1✔
258
     * 下拉菜单滚动到底部事件,可以用这个事件实现滚动加载
259
     */
260
    @Output() thyOnScrollToBottom: EventEmitter<void> = new EventEmitter<void>();
261

262
    /**
249✔
263
     * 下拉菜单展开和折叠状态事件
264
     */
265
    @Output() thyOnExpandStatusChange: EventEmitter<boolean> = new EventEmitter<boolean>();
80✔
266

74✔
267
    /**
64✔
268
     * 下拉列表是否显示搜索框
62✔
269
     * @default false
62✔
270
     */
271
    @Input({ transform: coerceBooleanProperty }) thyShowSearch: boolean;
272

2!
UNCOV
273
    /**
×
274
     * 选择框默认文字
×
275
     */
276
    @Input() thyPlaceHolder = this.locale().placeholder;
277

278
    /**
279
     * 是否使用服务端搜索,当为 true 时,将不再在前端进行过滤
280
     * @default false
281
     */
1!
282
    @Input({ transform: coerceBooleanProperty }) thyServerSearch: boolean;
1✔
283

284
    /**
285
     * 异步加载 loading 状态,false 表示加载中,true 表示加载完成
286
     */
1✔
287
    @Input({ transform: coerceBooleanProperty }) thyLoadState = true;
1!
288

1!
289
    /**
1✔
290
     * 是否自动设置选项第一条为高亮状态
1✔
291
     */
292
    @Input({ transform: coerceBooleanProperty }) thyAutoActiveFirstItem = true;
293

294
    /**
295
     * 下拉选择模式
296
     * @type 'multiple' | ''
11✔
297
     */
11✔
298
    @Input()
2✔
299
    set thyMode(value: SelectMode) {
2✔
300
        this.mode = value;
301
        this.instanceSelectionModel();
302
        this.getPositions();
9✔
303
        this.setDropDownClass();
9✔
304
    }
52✔
305

12✔
306
    get thyMode(): SelectMode {
307
        return this.mode;
308
    }
40✔
309

310
    /**
311
     * 操作图标类型
9✔
312
     * @type primary | success | danger | warning
9✔
313
     * @default primary
314
     */
315
    @Input()
316
    get thySize(): SelectControlSize {
317
        return this.size;
2✔
318
    }
1✔
319
    set thySize(value: SelectControlSize) {
320
        this.size = value;
1✔
321
    }
322

323
    /**
324
     * 数据为空时显示的提示文字
325
     */
24!
326
    @Input()
327
    set thyEmptyStateText(value: string) {
328
        this.emptyStateText = value;
3✔
329
    }
3✔
330

331
    /**
24✔
332
     * 搜索结果为空时显示的提示文字
333
     */
334
    @Input()
28✔
335
    set thyEmptySearchMessageText(value: string) {
28✔
336
        this.emptySearchMessageText = value;
28✔
337
    }
338

339
    /**
4✔
340
     * 滚动加载是否可用,只能当这个参数可以,下面的thyOnScrollToBottom事件才会触发
4✔
341
     */
1✔
342
    @Input({ transform: coerceBooleanProperty })
343
    thyEnableScrollLoad = false;
18✔
344

1✔
345
    /**
346
     * 单选( thyMode="" 或者不设置)时,选择框支持清除
1✔
347
     */
348
    @Input({ transform: coerceBooleanProperty }) thyAllowClear = false;
349

2✔
350
    /**
351
     * 是否禁用
352
     * @default false
353
     */
4✔
354
    @Input({ transform: coerceBooleanProperty })
3✔
355
    set thyDisabled(value: boolean) {
356
        this.disabled = value;
4✔
357
    }
1✔
358
    get thyDisabled(): boolean {
359
        return this.disabled;
3✔
360
    }
3✔
361

3✔
362
    /**
363
     * 排序比较函数
364
     */
56✔
365
    @Input() thySortComparator: (a: ThyOption, b: ThyOption, options: ThyOption[]) => number;
56✔
366

43✔
367
    /**
368
     * Footer 模板,默认值为空不显示 Footer
369
     * @type TemplateRef
370
     */
371
    @Input()
449✔
372
    thyFooterTemplate: TemplateRef<any>;
373

374
    /**
1,932✔
375
     * 弹出位置
376
     * @type ThyPlacement
377
     */
80✔
378
    @Input()
379
    thyPlacement: ThyPlacement;
380

1!
381
    /**
1✔
382
     * 自定义 Overlay Origin
383
     */
384
    @Input()
90✔
385
    thyOrigin: ElementRef | HTMLElement;
10✔
386

9✔
387
    /**
388
     * 自定义 Footer 模板容器 class
389
     */
390
    @Input()
80✔
391
    thyFooterClass = 'thy-custom-select-footer';
392

393
    /**
394
     * @private
83✔
395
     */
2✔
396
    @ContentChild('selectedDisplay') selectedValueDisplayRef: TemplateRef<any>;
397

81✔
398
    /**
81✔
399
     * 初始化时,是否展开面板
81✔
400
     * @default false
81✔
401
     */
81✔
402
    @Input({ transform: coerceBooleanProperty }) thyAutoExpand: boolean;
81✔
403

404
    /**
405
     * 是否弹出透明遮罩,如果显示遮罩则会阻止滚动区域滚动
57✔
406
     */
30✔
407
    @Input({ transform: coerceBooleanProperty })
30✔
408
    thyHasBackdrop = false;
30✔
409

30✔
410
    /**
30✔
411
     * 设置多选时最大显示的标签数量,0 表示不限制
412
     */
413
    @Input({ transform: numberAttribute }) thyMaxTagCount = 0;
414

47✔
415
    /**
47✔
416
     * 是否隐藏选择框边框
66✔
417
     * @default false
418
     */
47✔
419
    @Input({ transform: coerceBooleanProperty }) thyBorderless = false;
25✔
420

421
    isReactiveDriven = false;
422

22✔
423
    innerOptions: ThySelectOptionModel[];
2✔
424

425
    optionGroups: ThyOptionGroupModel[] = [];
426

20✔
427
    /**
428
     * option 列表
429
     * @type ThySelectOptionModel[]
47✔
430
     */
47✔
431
    @Input()
432
    set thyOptions(value: ThySelectOptionModel[]) {
81✔
433
        if (value === null) {
91!
434
            value = [];
91✔
435
        }
81✔
436
        this.innerOptions = value;
5✔
437
        this.isReactiveDriven = true;
438
        this.buildReactiveOptions();
76✔
439
    }
75✔
440

1✔
441
    options: QueryList<ThyOption>;
442

74✔
443
    /**
444
     * 目前只支持多选选中项的展示,默认为空,渲染文字模板,传入tag,渲染展示模板,
445
     * @default ''|tag
1✔
446
     */
447
    @Input() thyPreset: string = '';
448

449
    @ViewChild('trigger', { read: ElementRef, static: true }) trigger: ElementRef<HTMLElement>;
10!
UNCOV
450

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

453
    /**
10✔
454
     * @private
455
     */
456
    @ContentChildren(ThyOption, { descendants: true }) contentOptions: QueryList<ThyOption>;
457

458
    @ViewChildren(ThyOption) viewOptions: QueryList<ThyOption>;
179✔
459

2✔
460
    /**
461
     * @private
179✔
462
     */
463
    @ContentChildren(ThySelectOptionGroup) contentGroups: QueryList<ThySelectOptionGroup>;
464

465
    @ViewChildren(ThySelectOptionGroup) viewGroups: QueryList<ThySelectOptionGroup>;
466

179✔
UNCOV
467
    @HostListener('keydown', ['$event'])
×
468
    handleKeydown(event: KeyboardEvent): void {
×
469
        if (!this.disabled) {
470
            if (event.keyCode === ENTER) {
179✔
471
                event.stopPropagation();
90✔
472
            }
19✔
473
            this.panelOpen ? this.handleOpenKeydown(event) : this.handleClosedKeydown(event);
18✔
474
        }
475
    }
476

71✔
477
    get optionsChanges$() {
5✔
478
        this.options = this.isReactiveDriven ? this.viewOptions : this.contentOptions;
479
        let previousOptions: ThyOption[] = this.options.toArray();
480
        return this.options.changes.pipe(
481
            map(data => {
482
                return this.options.toArray();
4✔
483
            }),
4✔
484
            filter(data => {
4✔
485
                const res = previousOptions.length !== data.length || previousOptions.some((op, index) => op !== data[index]);
4✔
486
                previousOptions = data;
487
                return res;
4!
488
            })
1✔
489
        );
1✔
490
    }
491

3!
492
    private buildScrollStrategy() {
3✔
493
        if (this.scrollStrategyFactory && isFunction(this.scrollStrategyFactory)) {
2✔
494
            this.scrollStrategy = this.scrollStrategyFactory();
2✔
495
        } else {
496
            this.scrollStrategy = this.overlay.scrollStrategies.reposition();
497
        }
1✔
498
    }
499

500
    private isSearching = false;
501

502
    groupBy = (item: ThySelectOptionModel) => item.groupLabel;
13✔
503

13✔
504
    get placement(): ThyPlacement {
13✔
505
        return this.thyPlacement || this.config.placement;
13✔
506
    }
2✔
507

2✔
508
    constructor() {
509
        super();
11✔
510
        const selectConfig = this.selectConfig;
511

1✔
512
        this.config = { ...DEFAULT_SELECT_CONFIG, ...selectConfig };
1✔
513
        this.buildScrollStrategy();
514
    }
10✔
515

3✔
516
    writeValue(value: any): void {
3!
UNCOV
517
        this.modalValue = value;
×
518
        this.setSelectionByModelValue(this.modalValue);
×
519
    }
520

521
    ngOnInit() {
3✔
522
        this.getPositions();
523
        this.dropDownMinWidth = this.getDropdownMinWidth();
7✔
524
        if (!this.selectionModel) {
1✔
525
            this.instanceSelectionModel();
1✔
526
        }
1✔
527
        this.setDropDownClass();
8✔
528

7!
529
        if (isPlatformBrowser(this.platformId)) {
530
            this.thyClickDispatcher
531
                .clicked(0)
532
                .pipe(takeUntil(this.destroy$))
533
                .subscribe(event => {
6!
UNCOV
534
                    if (!this.elementRef.nativeElement.contains(event.target) && this.panelOpen) {
×
535
                        this.ngZone.run(() => {
536
                            this.close();
6✔
537
                            this.changeDetectorRef.markForCheck();
6✔
538
                        });
6!
539
                    }
540
                });
541
        }
542
    }
UNCOV
543

×
544
    buildOptionGroups(options: ThySelectOptionModel[]) {
545
        const optionGroups: ThyOptionGroupModel[] = [];
546
        const groups = [...new Set(options.filter(item => this.groupBy(item)).map(sub => this.groupBy(sub)))];
547
        const groupMap = new Map();
548
        groups.forEach(group => {
266✔
549
            const children = options.filter(item => this.groupBy(item) === group);
550
            const groupOption = {
551
                groupLabel: group,
178✔
552
                children: children
7✔
553
            };
554
            groupMap.set(group, groupOption);
178✔
555
        });
178✔
556
        options.forEach(option => {
7✔
557
            if (this.groupBy(option)) {
7✔
558
                const currentIndex = optionGroups.findIndex(item => item.groupLabel === this.groupBy(option));
559
                if (currentIndex === -1) {
178✔
560
                    const item = groupMap.get(this.groupBy(option));
101✔
561
                    optionGroups.push(item);
101✔
562
                }
563
            } else {
564
                optionGroups.push(option);
565
            }
179✔
566
        });
179✔
567
        return optionGroups;
104✔
568
    }
104✔
569

14✔
570
    buildReactiveOptions() {
14✔
571
        if (this.innerOptions.filter(item => this.groupBy(item)).length > 0) {
572
            this.optionGroups = this.buildOptionGroups(this.innerOptions);
573
        } else {
574
            this.optionGroups = this.innerOptions;
575
        }
179✔
576
    }
179✔
577

578
    getDropdownMinWidth(): number | null {
579
        const mode = this.thyDropdownWidthMode || this.config.dropdownWidthMode;
580
        let dropdownMinWidth: number | null = null;
266✔
581

266✔
582
        if ((mode as { minWidth: number })?.minWidth) {
43✔
583
            dropdownMinWidth = (mode as { minWidth: number }).minWidth;
584
        } else if (mode === 'min-width') {
585
            dropdownMinWidth = THY_SELECT_PANEL_MIN_WIDTH;
223✔
586
        } else {
587
            dropdownMinWidth = null;
266✔
588
        }
589

590
        return dropdownMinWidth;
591
    }
592

593
    ngAfterViewInit(): void {
426✔
594
        if (this.isReactiveDriven) {
236✔
595
            this.setup();
1✔
596
        }
1✔
597
    }
598

236✔
599
    ngAfterContentInit() {
600
        if (!this.isReactiveDriven) {
190✔
601
            this.setup();
28!
602
        }
28✔
603
    }
28✔
604

28✔
605
    setup() {
185✔
606
        this.optionsChanges$.pipe(startWith(null), takeUntil(this.destroy$)).subscribe(data => {
2✔
607
            this.resetOptions();
25✔
608
            this.initializeSelection();
22✔
609
            this.initKeyManager();
610
            if (this.isSearching) {
611
                this.highlightCorrectOption(false);
612
                this.isSearching = false;
613
            }
614
            this.changeDetectorRef.markForCheck();
162✔
615
            this.ngZone.onStable
184✔
616
                .asObservable()
617
                .pipe(take(1))
162✔
618
                .subscribe(() => {
131✔
619
                    if (this.cdkConnectedOverlay && this.cdkConnectedOverlay.overlayRef) {
620
                        this.cdkConnectedOverlay.overlayRef.updatePosition();
621
                    }
190✔
622
                });
623
        });
624

105✔
625
        if (this.thyAutoExpand) {
105!
UNCOV
626
            timer(0).subscribe(() => {
×
627
                this.changeDetectorRef.markForCheck();
×
628
                this.open();
629
                this.focus();
630
            });
105✔
631
        }
44✔
632
    }
633

105✔
634
    public get isHiddenOptions(): boolean {
33✔
635
        return this.options.toArray().every(option => option.hidden);
636
    }
105✔
637

56✔
638
    public onAttached(): void {
56✔
639
        this.cdkConnectedOverlay.positionChange.pipe(take(1)).subscribe(() => {
12✔
640
            if (this.panel) {
641
                if (this.keyManager.activeItem) {
642
                    ScrollToService.scrollToElement(this.keyManager.activeItem.element.nativeElement, this.panel.nativeElement);
643
                    this.changeDetectorRef.detectChanges();
105✔
644
                } else {
44✔
645
                    if (!this.empty) {
646
                        ScrollToService.scrollToElement(this.selectionModel.selected[0].element.nativeElement, this.panel.nativeElement);
105✔
647
                        this.changeDetectorRef.detectChanges();
49✔
648
                    }
649
                }
105✔
650
            }
651
        });
652
    }
56!
653

56✔
654
    public dropDownMouseMove(event: MouseEvent) {
56✔
655
        if (this.keyManager.activeItem) {
2✔
656
            this.keyManager.setActiveItem(-1);
1✔
657
        }
658
    }
659

660
    public onOptionsScrolled(elementRef: ElementRef) {
661
        const scroll = elementRef.nativeElement.scrollTop,
662
            height = elementRef.nativeElement.clientHeight,
81✔
663
            scrollHeight = elementRef.nativeElement.scrollHeight;
664

665
        if (scroll + height + 10 >= scrollHeight) {
81✔
666
            if (this.thyOnScrollToBottom.observers.length > 0) {
81✔
667
                this.ngZone.run(() => {
81✔
668
                    this.thyOnScrollToBottom.emit();
81✔
669
                });
81✔
670
            }
671
        }
81✔
672
    }
673

UNCOV
674
    public onSearchFilter(searchText: string) {
×
675
        searchText = searchText.trim();
676
        if (this.thyServerSearch) {
UNCOV
677
            this.isSearching = true;
×
678
            this.thyOnSearch.emit(searchText);
×
679
        } else {
×
680
            const options = this.options.toArray();
×
681
            options.forEach(option => {
682
                if (option.matchSearchText(searchText)) {
683
                    option.showOption();
684
                } else {
685
                    option.hideOption();
686
                }
283✔
687
            });
81✔
688
            this.highlightCorrectOption(false);
81✔
689
            this.updateCdkConnectedOverlayPositions();
690
        }
691
    }
692

172✔
693
    onBlur(event?: FocusEvent) {
172✔
694
        // Tab 聚焦后自动聚焦到 input 输入框,此分支下直接返回,无需触发 onTouchedFn
172✔
695
        if (elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-select-dropdown', 'thy-select'])) {
696
            return;
1✔
697
        }
1✔
698
        this.onTouchedFn();
699
    }
700

701
    onFocus(event?: FocusEvent) {
702
        // thyShowSearch 与 panelOpen 均为 true 时,点击 thySelectControl 需要触发自动聚焦到 input 的逻辑
703
        // manualFocusing 如果是手动聚焦,不触发自动聚焦到 input 的逻辑
704
        if (
705
            (this.thyShowSearch && this.panelOpen) ||
706
            (!this.manualFocusing &&
707
                !elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-select-dropdown', 'thy-custom-select']))
708
        ) {
709
            const inputElement: HTMLInputElement = this.elementRef.nativeElement.querySelector('input');
710
            inputElement.focus();
711
        }
712
        this.manualFocusing = false;
713
    }
714

715
    public focus(options?: FocusOptions): void {
716
        this.manualFocusing = true;
717
        this.elementRef.nativeElement.focus(options);
718
        this.manualFocusing = false;
719
    }
720

721
    public remove($event: { item: ThyOption; $eventOrigin: Event }) {
722
        $event.$eventOrigin.stopPropagation();
723
        if (this.disabled) {
724
            return;
725
        }
726
        if (!this.options.find(option => option === $event.item)) {
727
            $event.item.deselect();
728
            // fix option unselect can not emit changes;
729
            this.onSelect($event.item, true);
730
        } else {
731
            $event.item.deselect();
732
        }
733
    }
734

735
    public clearSelectValue(event?: Event) {
736
        if (event) {
737
            event.stopPropagation();
738
        }
739
        if (this.disabled) {
1✔
740
            return;
741
        }
742
        this.selectionModel.clear();
743
        this.changeDetectorRef.markForCheck();
744
        this.emitModelValueChange();
745
    }
746

747
    public updateCdkConnectedOverlayPositions(): void {
748
        setTimeout(() => {
749
            if (this.cdkConnectedOverlay && this.cdkConnectedOverlay.overlayRef) {
750
                this.cdkConnectedOverlay.overlayRef.updatePosition();
751
            }
156✔
752
        });
753
    }
754

755
    public get selected(): ThyOption | ThyOption[] {
756
        return this.isMultiple ? this.selectionModel.selected : this.selectionModel.selected[0];
757
    }
758

759
    public get isMultiple(): boolean {
760
        return this.mode === 'multiple';
761
    }
762

763
    public get empty(): boolean {
764
        return !this.selectionModel || this.selectionModel.isEmpty();
765
    }
766

767
    public getItemCount(): number {
768
        const group = this.isReactiveDriven ? this.viewGroups : this.contentGroups;
769
        return this.options.length + group.length;
770
    }
771

772
    public toggle(event: MouseEvent): void {
773
        if (this.panelOpen) {
774
            if (!this.thyShowSearch) {
775
                this.close();
776
            }
777
        } else {
778
            this.open();
779
        }
780
    }
781

782
    public open(): void {
783
        if (this.disabled || !this.options || this.panelOpen) {
784
            return;
785
        }
786
        this.triggerRectWidth = this.getOriginRectWidth();
787
        this.subscribeTriggerResize();
788
        this.panelOpen = true;
789
        this.highlightCorrectOption();
790
        this.thyOnExpandStatusChange.emit(this.panelOpen);
791
        this.changeDetectorRef.markForCheck();
792
    }
793

794
    public close(): void {
795
        if (this.panelOpen) {
796
            this.panelOpen = false;
797
            this.unsubscribeTriggerResize();
798
            this.thyOnExpandStatusChange.emit(this.panelOpen);
799
            this.changeDetectorRef.markForCheck();
800
            this.onTouchedFn();
801
        }
802
    }
803

804
    private emitModelValueChange() {
805
        const selectedValues = this.selectionModel.selected;
806
        const changeValue = selectedValues.map((option: ThyOption) => {
807
            return option.thyValue;
808
        });
809
        if (this.isMultiple) {
810
            this.modalValue = changeValue;
811
        } else {
812
            if (changeValue.length === 0) {
813
                this.modalValue = null;
814
            } else {
815
                this.modalValue = changeValue[0];
816
            }
817
        }
818
        this.onChangeFn(this.modalValue);
819
        this.updateCdkConnectedOverlayPositions();
820
    }
821

822
    private highlightCorrectOption(fromOpenPanel: boolean = true): void {
823
        if (this.keyManager && this.panelOpen) {
824
            if (fromOpenPanel) {
825
                if (this.keyManager.activeItem) {
826
                    return;
827
                }
828
                if (this.empty) {
829
                    if (!this.thyAutoActiveFirstItem) {
830
                        return;
831
                    }
832
                    this.keyManager.setFirstItemActive();
833
                } else {
834
                    this.keyManager.setActiveItem(this.selectionModel.selected[0]);
835
                }
836
            } else {
837
                if (!this.thyAutoActiveFirstItem) {
838
                    return;
839
                }
840
                // always set first option active
841
                this.keyManager.setFirstItemActive();
842
            }
843
        }
844
    }
845

846
    private initKeyManager() {
847
        if (this.keyManager && this.keyManager.activeItem) {
848
            this.keyManager.activeItem.setInactiveStyles();
849
        }
850
        this.keyManager = new ActiveDescendantKeyManager<ThyOption>(this.options)
851
            .withTypeAhead()
852
            .withWrap()
853
            .withVerticalOrientation()
854
            .withAllowedModifierKeys(['shiftKey']);
855

856
        this.keyManager.tabOut.pipe(takeUntil(this.destroy$)).subscribe(() => {
857
            this.focus();
858
            this.close();
859
        });
860
        this.keyManager.change.pipe(takeUntil(this.destroy$)).subscribe(() => {
861
            if (this.panelOpen && this.panel) {
862
                if (this.keyManager.activeItem) {
863
                    ScrollToService.scrollToElement(this.keyManager.activeItem.element.nativeElement, this.panel.nativeElement);
864
                }
865
            } else if (!this.panelOpen && !this.isMultiple && this.keyManager.activeItem) {
866
                this.keyManager.activeItem.selectViaInteraction();
867
            }
868
        });
869
    }
870

871
    private handleClosedKeydown(event: KeyboardEvent): void {
872
        const keyCode = event.keyCode;
873
        const isArrowKey = keyCode === DOWN_ARROW || keyCode === UP_ARROW || keyCode === LEFT_ARROW || keyCode === RIGHT_ARROW;
874
        const isOpenKey = keyCode === ENTER || keyCode === SPACE;
875
        const manager = this.keyManager;
876

877
        // Open the select on ALT + arrow key to match the native <select>
878
        if ((isOpenKey && !hasModifierKey(event)) || ((this.isMultiple || event.altKey) && isArrowKey)) {
879
            event.preventDefault(); // prevents the page from scrolling down when pressing space
880
            this.open();
881
        } else if (!this.isMultiple) {
882
            if (keyCode === HOME || keyCode === END) {
883
                keyCode === HOME ? manager.setFirstItemActive() : manager.setLastItemActive();
884
                event.preventDefault();
885
            } else {
886
                manager.onKeydown(event);
887
            }
888
        }
889
    }
890

891
    private handleOpenKeydown(event: KeyboardEvent): void {
892
        const keyCode = event.keyCode;
893
        const isArrowKey = keyCode === DOWN_ARROW || keyCode === UP_ARROW;
894
        const manager = this.keyManager;
895

896
        if (keyCode === HOME || keyCode === END) {
897
            event.preventDefault();
898
            keyCode === HOME ? manager.setFirstItemActive() : manager.setLastItemActive();
899
        } else if (isArrowKey && event.altKey) {
900
            // Close the select on ALT + arrow key to match the native <select>
901
            event.preventDefault();
902
            this.close();
903
        } else if ((keyCode === ENTER || keyCode === SPACE) && (manager.activeItem || !this.empty) && !hasModifierKey(event)) {
904
            event.preventDefault();
905
            if (!manager.activeItem) {
906
                if (manager.activeItemIndex === -1 && !this.empty) {
907
                    manager.setActiveItem(this.selectionModel.selected[0]);
908
                }
909
            }
910
            manager.activeItem.selectViaInteraction();
911
        } else if (this.isMultiple && keyCode === A && event.ctrlKey) {
912
            event.preventDefault();
913
            const hasDeselectedOptions = this.options.some(opt => !opt.disabled && !opt.selected);
914

915
            this.options.forEach(option => {
916
                if (!option.disabled) {
917
                    hasDeselectedOptions ? option.select() : option.deselect();
918
                }
919
            });
920
        } else {
921
            if (manager.activeItemIndex === -1 && !this.empty) {
922
                manager.setActiveItem(this.selectionModel.selected[0]);
923
            }
924
            const previouslyFocusedIndex = manager.activeItemIndex;
925

926
            manager.onKeydown(event);
927

928
            if (
929
                this.isMultiple &&
930
                isArrowKey &&
931
                event.shiftKey &&
932
                manager.activeItem &&
933
                manager.activeItemIndex !== previouslyFocusedIndex
934
            ) {
935
                manager.activeItem.selectViaInteraction();
936
            }
937
        }
938
    }
939

940
    private getPositions() {
941
        this.dropDownPositions = getFlexiblePositions(this.thyPlacement || this.config.placement, this.defaultOffset);
942
    }
943

944
    private instanceSelectionModel() {
945
        if (this.selectionModel) {
946
            this.selectionModel.clear();
947
        }
948
        this.selectionModel = new SelectionModel<ThyOption>(this.isMultiple);
949
        if (this.selectionModelSubscription) {
950
            this.selectionModelSubscription.unsubscribe();
951
            this.selectionModelSubscription = null;
952
        }
953
        this.selectionModelSubscription = this.selectionModel.changed.pipe(takeUntil(this.destroy$)).subscribe(event => {
954
            event.added.forEach(option => option.select());
955
            event.removed.forEach(option => option.deselect());
956
        });
957
    }
958

959
    private resetOptions() {
960
        const changedOrDestroyed$ = merge(this.optionsChanges$, this.destroy$);
961

962
        this.optionSelectionChanges.pipe(takeUntil(changedOrDestroyed$)).subscribe((event: ThyOptionSelectionChangeEvent) => {
963
            this.onSelect(event.option, event.isUserInput);
964
            if (event.isUserInput && !this.isMultiple && this.panelOpen) {
965
                this.close();
966
                this.focus();
967
            }
968
        });
969
    }
970

971
    private initializeSelection() {
972
        Promise.resolve().then(() => {
973
            this.setSelectionByModelValue(this.modalValue);
974
        });
975
    }
976

977
    private setDropDownClass() {
978
        let modeClass = '';
979
        if (this.isMultiple) {
980
            modeClass = `thy-select-dropdown-${this.mode}`;
981
        } else {
982
            modeClass = `thy-select-dropdown-single`;
983
        }
984
        this.dropDownClass = {
985
            [`thy-select-dropdown`]: true,
986
            [modeClass]: true
987
        };
988
    }
989

990
    private setSelectionByModelValue(modalValue: any) {
991
        if (helpers.isUndefinedOrNull(modalValue)) {
992
            if (this.selectionModel.selected.length > 0) {
993
                this.selectionModel.clear();
994
                this.changeDetectorRef.markForCheck();
995
            }
996
            return;
997
        }
998
        if (this.isMultiple) {
999
            if (isArray(modalValue)) {
1000
                const selected = [...this.selectionModel.selected];
1001
                this.selectionModel.clear();
1002
                (modalValue as Array<any>).forEach(itemValue => {
1003
                    const option =
1004
                        this.options.find(_option => _option.thyValue === itemValue) ||
1005
                        selected.find(_option => _option.thyValue === itemValue);
1006
                    if (option) {
1007
                        this.selectionModel.select(option);
1008
                    }
1009
                });
1010
            }
1011
        } else {
1012
            const selectedOption = this.options?.find(option => {
1013
                return option.thyValue === modalValue;
1014
            });
1015
            if (selectedOption) {
1016
                this.selectionModel.select(selectedOption);
1017
            }
1018
        }
1019
        this.changeDetectorRef.markForCheck();
1020
    }
1021

1022
    private onSelect(option: ThyOption, isUserInput: boolean) {
1023
        const wasSelected = this.selectionModel.isSelected(option);
1024

1025
        if (option.thyValue == null && !this.isMultiple) {
1026
            option.deselect();
1027
            this.selectionModel.clear();
1028
        } else {
1029
            if (wasSelected !== option.selected) {
1030
                option.selected ? this.selectionModel.select(option) : this.selectionModel.deselect(option);
1031
            }
1032

1033
            if (isUserInput) {
1034
                this.keyManager.setActiveItem(option);
1035
            }
1036

1037
            if (this.isMultiple) {
1038
                this.sortValues();
1039
                if (isUserInput) {
1040
                    this.focus();
1041
                }
1042
            }
1043
        }
1044

1045
        if (wasSelected !== this.selectionModel.isSelected(option)) {
1046
            this.emitModelValueChange();
1047
        }
1048
        if (!this.isMultiple) {
1049
            this.onTouchedFn();
1050
        }
1051
        this.changeDetectorRef.markForCheck();
1052
    }
1053

1054
    private sortValues() {
1055
        if (this.isMultiple) {
1056
            const options = this.options.toArray();
1057

1058
            if (this.thySortComparator) {
1059
                this.selectionModel.sort((a, b) => {
1060
                    return this.thySortComparator(a, b, options);
1061
                });
1062
            }
1063
        }
1064
    }
1065

1066
    private getOriginRectWidth() {
1067
        return this.thyOrigin ? coerceElement(this.thyOrigin).offsetWidth : this.trigger.nativeElement.offsetWidth;
1068
    }
1069

1070
    private subscribeTriggerResize(): void {
1071
        this.unsubscribeTriggerResize();
1072
        this.ngZone.runOutsideAngular(() => {
1073
            this.resizeSubscription = new Observable<number>(observer => {
1074
                const resize = new ResizeObserver((entries: ResizeObserverEntry[]) => {
1075
                    observer.next();
1076
                });
1077
                resize.observe(this.trigger.nativeElement);
1078
            })
1079
                .pipe(
1080
                    startWith(),
1081
                    map(() => {
1082
                        return this.getOriginRectWidth();
1083
                    }),
1084
                    distinctUntilChanged()
1085
                )
1086
                .subscribe((width: number) => {
1087
                    this.ngZone.run(() => {
1088
                        this.triggerRectWidth = width;
1089
                        this.updateCdkConnectedOverlayPositions();
1090
                        this.changeDetectorRef.markForCheck();
1091
                    });
1092
                });
1093
        });
1094
    }
1095

1096
    private unsubscribeTriggerResize(): void {
1097
        if (this.resizeSubscription) {
1098
            this.resizeSubscription.unsubscribe();
1099
            this.resizeSubscription = null;
1100
        }
1101
    }
1102

1103
    ngOnDestroy() {
1104
        this.unsubscribeTriggerResize();
1105
        this.destroy$.next();
1106
        this.destroy$.complete();
1107
    }
1108
}
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