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

atinc / ngx-tethys / #102

26 May 2026 08:11AM UTC coverage: 91.111% (+0.7%) from 90.407%
#102

push

web-flow
build: bump docgeni to 2.8.0-next.5 (#3809)

4571 of 5491 branches covered (83.25%)

Branch coverage included in aggregate %.

13141 of 13949 relevant lines covered (94.21%)

966.75 hits per line

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

92.85
/src/select/custom-select/custom-select.component.ts
1
import {
2
    getFlexiblePositions,
3
    injectPanelEmptyIcon,
4
    thyAnimationZoom,
5
    ScrollToService,
6
    TabIndexDisabledControlValueAccessorMixin,
7
    ThyClickDispatcher,
8
    ThyPlacement
9
} from 'ngx-tethys/core';
10
import { ThyEmpty } from 'ngx-tethys/empty';
11
import { ThyLoading } from 'ngx-tethys/loading';
12
import {
13
    SelectControlSize,
14
    ThyOption,
15
    ThyOptionRender,
16
    ThySelectControl,
17
    ThySelectOptionGroup,
18
    ThyStopPropagationDirective,
19
    ThyOptionGroupRender,
20
    SelectOptionBase,
21
    ThyViewOutletDirective,
22
    ThyScrollDirective
23
} from 'ngx-tethys/shared';
24
import {
25
    A,
26
    coerceBooleanProperty,
27
    DOWN_ARROW,
28
    elementMatchClosest,
29
    END,
30
    ENTER,
31
    TAB,
32
    FunctionProp,
33
    hasModifierKey,
34
    helpers,
35
    HOME,
36
    isFunction,
37
    SPACE,
38
    UP_ARROW
39
} from 'ngx-tethys/util';
40
import { from, Observable, Subscription, timer } from 'rxjs';
41
import { distinctUntilChanged, filter, map, startWith, take } from 'rxjs/operators';
42
import { coerceElement } from '@angular/cdk/coercion';
43
import { CdkConnectedOverlay, CdkOverlayOrigin, ConnectionPositionPair, Overlay, ScrollStrategy } from '@angular/cdk/overlay';
44
import { isPlatformBrowser, NgClass, NgTemplateOutlet } from '@angular/common';
45
import {
46
    AfterViewInit,
47
    ChangeDetectionStrategy,
48
    ChangeDetectorRef,
49
    Component,
50
    ElementRef,
51
    forwardRef,
52
    HostListener,
53
    Input,
54
    NgZone,
55
    numberAttribute,
56
    OnDestroy,
57
    OnInit,
58
    output,
59
    PLATFORM_ID,
60
    QueryList,
61
    TemplateRef,
62
    ViewChild,
63
    viewChild,
64
    ViewChildren,
65
    inject,
66
    Signal,
67
    input,
68
    contentChild,
69
    untracked,
70
    contentChildren,
71
    afterRenderEffect,
72
    signal,
73
    computed,
74
    linkedSignal,
75
    WritableSignal,
76
    DestroyRef
77
} from '@angular/core';
78
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
79
import {
80
    DEFAULT_SELECT_CONFIG,
81
    THY_SELECT_CONFIG,
82
    THY_SELECT_SCROLL_STRATEGY,
83
    ThyDropdownWidthMode,
84
    ThySelectConfig
85
} from '../select.config';
86
import { injectLocale, ThySelectLocale } from 'ngx-tethys/i18n';
87
import { SafeAny } from 'ngx-tethys/types';
88
import { CdkVirtualScrollViewport, ScrollDispatcher, ScrollingModule } from '@angular/cdk/scrolling';
89
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
90
import { isUndefinedOrNull } from '@tethys/cdk/is';
91

92
export type SelectMode = 'multiple' | '';
93

94
export type ThySelectTriggerType = 'click' | 'hover';
95

96
export const SELECT_PANEL_MAX_HEIGHT = 300;
97

98
export const SELECT_OPTION_MAX_HEIGHT = 40;
1✔
99

100
export const SELECT_PANEL_PADDING_TOP = 12;
1✔
101

102
export const SELECT_PANEL_PADDING_BOTTOM = 12;
1✔
103

104
export const THY_SELECT_PANEL_MIN_WIDTH = 200;
1✔
105

106
export interface OptionValue {
1✔
107
    thyLabelText?: string;
108
    thyValue?: string;
109
    thyDisabled?: boolean;
110
    thyShowOptionCustom?: boolean;
111
    thySearchKey?: string;
112
}
113

114
export interface ThySelectOptionModel {
115
    value?: string | number;
116
    disabled?: boolean;
117
    label?: string;
118
    icon?: string;
119
    groupLabel?: string;
120
}
121

122
interface ThySelectFlattedItem {
123
    type: 'option' | 'group';
124
    value?: string | number;
125
    rawValue?: any;
126
    label?: string;
127
    showOptionCustom?: boolean;
128
    template?: TemplateRef<any>;
1✔
129
    disabled?: boolean;
130
    searchKey?: string;
131
    groupLabel?: string;
132
}
133

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

172✔
188
    disabled = false;
172✔
189

190
    mode: SelectMode = '';
172✔
191

192
    scrollTop = 0;
172✔
193

194
    defaultOffset = 4;
172✔
195

196
    readonly dropDownClass = computed<{ [key: string]: boolean }>(() => {
172✔
197
        const modeClass = `thy-select-dropdown-${this.isMultiple() ? 'multiple' : 'single'}`;
198
        return {
172✔
199
            [`thy-select-dropdown`]: true,
200
            [modeClass]: true
201
        };
202
    });
172✔
203

204
    readonly dropDownMinWidth = computed<number | null>(() => {
205
        const mode = this.thyDropdownWidthMode() || this.config.dropdownWidthMode;
206
        let dropdownMinWidth: number | null = null;
207

208
        if ((mode as { minWidth: number })?.minWidth) {
172✔
209
            dropdownMinWidth = (mode as { minWidth: number }).minWidth;
210
        } else if (mode === 'min-width') {
211
            dropdownMinWidth = THY_SELECT_PANEL_MIN_WIDTH;
212
        } else {
213
            dropdownMinWidth = null;
214
        }
215

216
        return dropdownMinWidth;
217
    });
218

219
    /**
220
     * 设置下拉框的最小宽度,默认值 `match-select`,表示与输入框的宽度一致;`min-width` 表示最小宽度为200px;支持自定义最小宽度,比如传 `{minWidth: 150}` 表示最小宽度为150px
221
     * @default match-select
222
     */
223
    readonly thyDropdownWidthMode = input<ThyDropdownWidthMode>();
224

225
    readonly placement = computed<ThyPlacement>(() => {
172✔
226
        return this.thyPlacement() || this.config.placement!;
227
    });
228

229
    readonly animateEnterClass = computed<string>(() => {
172✔
230
        const placement = this.placement();
231
        if (placement === 'top' || placement === 'bottom') {
172✔
232
            return thyAnimationZoom.yEnter;
179✔
233
        } else if (placement === 'left' || placement === 'right') {
888✔
234
            return thyAnimationZoom.xEnter;
235
        } else {
×
236
            return thyAnimationZoom.enter;
237
        }
×
238
    });
239

240
    readonly animateLeaveClass = computed<string>(() => {
241
        const placement = this.placement();
172✔
242
        if (placement === 'top' || placement === 'bottom') {
243
            return thyAnimationZoom.yLeave;
244
        } else if (placement === 'left' || placement === 'right') {
245
            return thyAnimationZoom.xLeave;
172✔
246
        } else {
247
            return thyAnimationZoom.leave;
248
        }
249
    });
250

172✔
251
    readonly dropDownPositions = computed<ConnectionPositionPair[]>(() => {
252
        return getFlexiblePositions(this.placement(), this.defaultOffset);
253
    });
254

255
    public thyItemSize = input(SELECT_OPTION_MAX_HEIGHT, { transform: value => numberAttribute(value) });
172✔
256

257
    readonly virtualHeight = computed<number>(() => {
258
        const maxVirtualHeight = SELECT_PANEL_MAX_HEIGHT - SELECT_PANEL_PADDING_TOP - SELECT_PANEL_PADDING_BOTTOM;
259
        const height = this.filteredGroupsAndOptions().length * this.thyItemSize();
260
        return Math.min(height, maxVirtualHeight);
172✔
261
    });
262

263
    /**
264
     * 出现滚动条时,视觉上能看到的最大选项个数
265
     */
172✔
266
    private maxItemLength = computed(() => {
267
        return Math.round(this.virtualHeight() / this.thyItemSize());
268
    });
269

270
    public triggerRectWidth: WritableSignal<number | undefined> = signal(undefined);
172✔
271

272
    public scrollStrategy?: ScrollStrategy;
273

274
    private resizeSubscription?: Subscription | null;
275

172✔
276
    /**
277
     * 手动聚焦中的标识
278
     */
279
    private manualFocusing = false;
280

172✔
281
    private config: ThySelectConfig;
282

283
    private destroyRef = inject(DestroyRef);
284

285
    readonly cdkConnectedOverlay = viewChild<CdkConnectedOverlay>(CdkConnectedOverlay);
172✔
286

287
    panelOpen = false;
288

289
    /**
290
     * 搜索时回调
291
     */
172✔
292
    readonly thyOnSearch = output<string>();
293

294
    /**
295
     * 下拉菜单滚动到底部事件,可以用这个事件实现滚动加载
296
     */
297
    readonly thyOnScrollToBottom = output<void>();
298

172✔
299
    /**
300
     * 下拉菜单展开和折叠状态事件
301
     */
302
    readonly thyOnExpandStatusChange = output<boolean>();
303

172✔
304
    /**
305
     * 下拉列表是否显示搜索框
306
     */
307
    readonly thyShowSearch = input(false, { transform: coerceBooleanProperty });
308

172!
309
    /**
310
     * 选择框默认文字
311
     */
312
    readonly thyPlaceHolder = input<string>(this.locale().placeholder);
313

172✔
314
    /**
315
     * 是否使用服务端搜索,当为 true 时,将不再在前端进行过滤
316
     */
317
    readonly thyServerSearch = input(false, { transform: coerceBooleanProperty });
318

172✔
319
    /**
320
     * 异步加载 loading 状态,false 表示加载中,true 表示加载完成
321
     */
322
    readonly thyLoadState = input(true, { transform: coerceBooleanProperty });
323

324
    /**
325
     * 是否自动设置选项第一条为高亮状态
326
     */
114✔
327
    readonly thyAutoActiveFirstItem = input(true, { transform: coerceBooleanProperty });
328

329
    /**
910✔
330
     * 下拉选择模式
331
     * @type 'multiple' | ''
332
     */
333
    readonly thyMode = input<SelectMode>('');
334

335
    /**
172✔
336
     * 操作图标类型
337
     * @type primary | success | danger | warning
338
     * @default primary
339
     */
340
    readonly thySize = input<SelectControlSize>();
172✔
341

342
    /**
343
     * 数据为空时显示的提示文字
344
     */
345
    readonly thyEmptyStateText = input(this.locale().empty, { transform: (value: string) => value || this.locale().empty });
346

172✔
347
    /**
348
     * 搜索结果为空时显示的提示文字
349
     */
350
    readonly thyEmptySearchMessageText = input(this.locale().empty, { transform: (value: string) => value || this.locale().empty });
351

172✔
352
    /**
353
     * 滚动加载是否可用,只能当这个参数可以,下面的thyOnScrollToBottom事件才会触发
354
     */
355
    readonly thyEnableScrollLoad = input(false, { transform: coerceBooleanProperty });
356

172✔
357
    /**
358
     * 单选( thyMode="" 或者不设置)时,选择框支持清除
359
     */
360
    readonly thyAllowClear = input(false, { transform: coerceBooleanProperty });
361

172✔
362
    /**
363
     * 是否禁用
364
     * @default false
365
     */
366
    @Input({ transform: coerceBooleanProperty })
172✔
367
    set thyDisabled(value: boolean) {
368
        this.disabled = value;
369
    }
370
    get thyDisabled(): boolean {
371
        return this.disabled;
172✔
372
    }
373

374
    /**
375
     * 排序比较函数
376
     */
172✔
377
    readonly thySortComparator = input<(a: ThyOption, b: ThyOption, options: ThyOption[]) => number>();
378

×
379
    /**
×
380
     * Footer 模板,默认值为空不显示 Footer
381
     */
382
    readonly thyFooterTemplate = input<TemplateRef<any>>();
383

384
    /**
385
     * 弹出位置
386
     * @type ThyPlacement
172✔
387
     */
388
    readonly thyPlacement = input<ThyPlacement>();
172✔
389

390
    /**
391
     * 自定义 Overlay Origin
392
     */
172✔
393
    readonly thyOrigin = input<ElementRef | HTMLElement>();
394

395
    /**
396
     * 自定义 Footer 模板容器 class
397
     */
172✔
398
    readonly thyFooterClass = input<string>('thy-custom-select-footer');
399

4!
400
    /**
×
401
     * @private
402
     */
4✔
403
    readonly selectedValueDisplayRef = contentChild<TemplateRef<any>>('selectedDisplay');
4✔
404

4✔
405
    /**
4✔
406
     * 初始化时,是否展开面板
407
     */
408
    readonly thyAutoExpand = input(false, { transform: coerceBooleanProperty });
409

410
    /**
411
     * 是否弹出透明遮罩,如果显示遮罩则会阻止滚动区域滚动
412
     */
413
    readonly thyHasBackdrop = input(false, { transform: coerceBooleanProperty });
414

415
    /**
172✔
416
     * 设置多选时最大显示的标签数量,0 表示不限制,'auto' 表示自动计算显示数量,默认值为 0
417
     */
418
    readonly thyMaxTagCount = input(0, {
419
        transform: (value: number | 'auto') => {
172✔
420
            if (value === 'auto') return 'auto';
421
            return numberAttribute(value, 0);
422
        }
423
    });
424

425
    /**
426
     * 是否隐藏选择框边框
427
     */
428
    readonly thyBorderless = input(false, { transform: coerceBooleanProperty });
429

430
    readonly panel = viewChild<ElementRef<HTMLElement>>('panel');
431

172✔
432
    /**
433
     * 是否启用虚拟滚动,默认值为 false
172✔
434
     */
435
    readonly thyVirtualScroll = input(false, { transform: coerceBooleanProperty });
436

437
    private scrolledIndex = 0;
17✔
438

17✔
439
    readonly cdkVirtualScrollViewport = viewChild<CdkVirtualScrollViewport>(CdkVirtualScrollViewport);
5✔
440

441
    private shouldActivateOption = false;
17✔
442

443
    /**
444
     * option 列表
445
     */
446
    readonly thyOptions = input(undefined, {
350✔
447
        transform: (value: ThySelectOptionModel[]) => {
350✔
448
            if (helpers.isUndefinedOrNull(value)) {
350✔
449
                value = [];
450
            }
13✔
451
            return value;
452
        }
453
    });
13✔
454

13✔
455
    readonly keywords = signal<string>('');
13✔
456

457
    /**
458
     * 目前只支持多选选中项的展示,默认为空,渲染文字模板,传入tag,渲染展示模板,
459
     * @default ''|tag
460
     */
461
    readonly thyPreset = input<string>('');
172✔
462

161✔
463
    @ViewChild('trigger', { read: ElementRef, static: true }) trigger!: ElementRef<HTMLElement>;
464

11✔
465
    private readonly options = contentChildren<ThyOption>(ThyOption, { descendants: true });
466

467
    private readonly groups = contentChildren<ThySelectOptionGroup>(ThySelectOptionGroup, { descendants: true });
468

172✔
469
    /**
470
     * 所有分组和选项
172✔
471
     */
472
    private readonly allGroupsAndOptions = signal<ThySelectFlattedItem[]>([]);
473

1,562✔
474
    /**
475
     * 渲染的分组和选项,基于 keywords 过滤后
476
     */
477
    readonly filteredGroupsAndOptions = computed<ThySelectFlattedItem[]>(() => {
172✔
478
        return this.buildFilteredGroupsAndOptions();
172✔
479
    });
480

172✔
481
    /**
172✔
482
     * 渲染的选项
483
     */
172✔
484
    private readonly filteredOptions = computed<ThySelectFlattedItem[]>(() => {
179✔
485
        return this.filteredGroupsAndOptions().filter(item => item.type === 'option');
179✔
486
    });
179✔
487

179✔
488
    private readonly filteredOptionsMap = computed<Map<SafeAny, ThySelectFlattedItem>>(() => {
179✔
489
        return this.filteredOptions().reduce((map, item) => {
490
            if (!isUndefinedOrNull(item.value)) {
491
                map.set(item.value, item);
492
            }
493
            return map;
494
        }, new Map<SafeAny, ThySelectFlattedItem>());
247✔
495
    });
247✔
496

497
    /**
498
     * 当前选中的值
499
     */
188✔
500
    readonly selectedValues = linkedSignal<SelectMode, SafeAny[]>({
188✔
501
        source: () => this.thyMode(),
188✔
502
        computation: () => {
171✔
503
            return [];
504
        }
188✔
505
    });
506

188✔
507
    readonly selectedValuesMap = computed<Map<SafeAny, boolean>>(() => {
188✔
508
        return new Map((this.selectedValues() || []).map(value => [value, true]));
509
    });
510

511
    /**
198✔
512
     * 传给 selectControl 指令的选中值
4✔
513
     */
4✔
514
    readonly selectedOptions: WritableSignal<SelectOptionBase | SelectOptionBase[] | null> = linkedSignal<SelectMode, SafeAny[] | null>({
4✔
515
        source: () => this.thyMode(),
516
        computation: () => {
517
            return this.thyMode() === 'multiple' ? [] : null;
518
        }
519
    });
520

521
    @ViewChildren(ThyOptionRender) optionRenders!: QueryList<ThyOptionRender>;
522

1✔
523
    @HostListener('keydown', ['$event'])
3✔
524
    keydown(event: KeyboardEvent): void {
1✔
525
        if (this.disabled) {
1✔
526
            return;
3✔
527
        }
1✔
528
        if (event.keyCode === ENTER) {
529
            event.stopPropagation();
530
        }
531

1✔
532
        this.handleKeydown(event);
533
    }
1✔
534

3✔
535
    get optionsChanges$() {
2✔
536
        let previousOptions: ThyOptionRender[] = this.optionRenders?.toArray();
2✔
537
        return this.optionRenders.changes.pipe(
1✔
538
            map(data => {
1✔
539
                return this.optionRenders.toArray();
540
            }),
541
            filter(data => {
1✔
542
                const res = previousOptions.length !== data.length || previousOptions.some((op, index) => op !== data[index]);
543
                previousOptions = data;
544
                return res;
1✔
545
            })
546
        );
547
    }
548

12✔
549
    private buildScrollStrategy() {
1✔
550
        if (this.scrollStrategyFactory && isFunction(this.scrollStrategyFactory)) {
551
            this.scrollStrategy = this.scrollStrategyFactory();
3✔
552
        } else {
553
            this.scrollStrategy = this.overlay.scrollStrategies.reposition();
554
        }
555
    }
556

188✔
557
    constructor() {
188✔
558
        super();
559
        const selectConfig = this.selectConfig;
188✔
560

1✔
561
        this.config = { ...DEFAULT_SELECT_CONFIG, ...selectConfig };
187✔
562
        this.buildScrollStrategy();
4✔
563

564
        afterRenderEffect(() => {
183✔
565
            const options = this.options();
566
            const groups = this.groups();
567
            const reactiveOptions = this.thyOptions();
188✔
568

569
            untracked(() => {
570
                this.buildAllGroupsAndOptions();
571
            });
171✔
572
        });
3✔
573

574
        afterRenderEffect(() => {
575
            this.updateSelectedOptions();
576
        });
577
    }
171✔
578

168✔
579
    writeValue(value: SafeAny): void {
580
        let selectedValues: SafeAny[];
581
        if (helpers.isUndefinedOrNull(value)) {
582
            selectedValues = [];
583
        } else if (this.isMultiple()) {
171✔
584
            selectedValues = value;
179✔
585
        } else {
179✔
586
            selectedValues = [value];
179✔
587
        }
179✔
588
        this.selectedValues.set(selectedValues);
1✔
589
    }
1✔
590

591
    ngOnInit() {
179✔
592
        if (isPlatformBrowser(this.platformId)) {
179✔
593
            this.thyClickDispatcher
594
                .clicked(0)
595
                .pipe(takeUntilDestroyed(this.destroyRef))
596
                .subscribe(event => {
179✔
597
                    if (!this.elementRef.nativeElement.contains(event.target) && this.panelOpen) {
41✔
598
                        this.ngZone.run(() => {
599
                            this.close();
600
                            this.changeDetectorRef.markForCheck();
601
                        });
602
                    }
171✔
603
                });
1✔
604
        }
1✔
605
    }
1✔
606

1✔
607
    ngAfterViewInit(): void {
608
        this.setup();
609
    }
610

611
    private setup() {
612
        this.optionsChanges$.pipe(startWith(null), takeUntilDestroyed(this.destroyRef)).subscribe(() => {
256✔
613
            // Don't scroll to default highlighted option when scroll load more options
614
            if (this.shouldActivateOption) {
615
                this.shouldActivateOption = false;
616
                this.scrollToActivatedOption();
80✔
617
            }
618

619
            from(Promise.resolve())
74✔
620
                .pipe(take(1))
64✔
621
                .subscribe(() => {
62✔
622
                    if (this.cdkConnectedOverlay() && this.cdkConnectedOverlay()!.overlayRef) {
62✔
623
                        this.cdkConnectedOverlay()!.overlayRef.updatePosition();
624
                    }
2!
625
                });
×
626
        });
627

628
        if (this.thyAutoExpand()) {
629
            timer(0).subscribe(() => {
×
630
                this.changeDetectorRef.markForCheck();
631
                this.open();
632
                this.focus();
633
            });
634
        }
635
    }
636

637
    private buildAllGroupsAndOptions() {
1✔
638
        let allGroupsAndOptions: ThySelectFlattedItem[];
1✔
639
        const isReactiveDriven = this.thyOptions() && this.thyOptions()!.length > 0;
640
        if (isReactiveDriven) {
641
            allGroupsAndOptions = this.allGroupsAndOptionsByReactive();
642
        } else {
643
            allGroupsAndOptions = this.allGroupsAndOptionsByTemplate();
1✔
644
        }
1✔
645
        this.allGroupsAndOptions.set(allGroupsAndOptions);
1✔
646
    }
647

1✔
648
    private allGroupsAndOptionsByReactive(): ThySelectFlattedItem[] {
1✔
649
        const options = this.thyOptions()!;
1✔
650
        const groupMap = new Map<string, ThySelectOptionModel[]>();
651
        const ungroupedOptions: ThySelectOptionModel[] = [];
652

653
        const groupsAndOptions: ThySelectFlattedItem[] = [];
654

655
        for (const option of options) {
11✔
656
            if (option.groupLabel) {
11✔
657
                if (!groupMap.has(option.groupLabel)) {
2✔
658
                    groupMap.set(option.groupLabel, []);
2✔
659
                }
660
                groupMap.get(option.groupLabel)!.push(option);
9✔
661
            } else {
9✔
662
                ungroupedOptions.push(option);
52✔
663
            }
12✔
664
        }
665

40✔
666
        for (const [groupLabel, groupOptions] of groupMap) {
667
            groupsAndOptions.push({
668
                type: 'group',
9✔
669
                label: groupLabel
9✔
670
            });
671
            for (const option of groupOptions) {
672
                groupsAndOptions.push({
673
                    type: 'option',
674
                    value: option.value,
675
                    label: option.label,
2✔
676
                    rawValue: option,
1✔
677
                    showOptionCustom: false,
678
                    disabled: !!option.disabled,
1✔
679
                    groupLabel: option.groupLabel
680
                });
681
            }
682
        }
683

684
        for (const option of ungroupedOptions) {
4✔
685
            groupsAndOptions.push({
11!
686
                type: 'option',
687
                value: option.value,
688
                label: option.label,
689
                rawValue: option,
3✔
690
                showOptionCustom: false,
3✔
691
                disabled: !!option.disabled
692
            });
4✔
693
        }
694

695
        return groupsAndOptions;
696
    }
28✔
697

28✔
698
    private allGroupsAndOptionsByTemplate(): ThySelectFlattedItem[] {
28✔
699
        const options = this.options();
700
        const groups = this.groups();
701

702
        let groupsAndOptions: ThySelectFlattedItem[] = [];
4✔
703

4✔
704
        if (options && options.length > 0) {
1✔
705
            groupsAndOptions = options.map((option: ThyOption) => {
706
                const {
18✔
707
                    thyValue,
1✔
708
                    thyRawValue,
709
                    thyLabelText,
1✔
710
                    thyShowOptionCustom,
711
                    thyDisabled,
2✔
712
                    template,
713
                    suffixTemplate,
714
                    thySearchKey,
715
                    groupLabel
716
                } = option;
4✔
717

3✔
718
                return {
719
                    type: 'option',
4✔
720
                    value: thyValue(),
1✔
721
                    rawValue: thyRawValue(),
722
                    label: thyLabelText(),
3✔
723
                    showOptionCustom: thyShowOptionCustom(),
3✔
724
                    disabled: thyDisabled(),
3✔
725
                    template: template(),
726
                    suffixTemplate: suffixTemplate(),
727
                    searchKey: thySearchKey(),
728
                    groupLabel: groupLabel
56✔
729
                };
56✔
730
            });
43✔
731
        }
732

733
        if (groups && groups.length > 0) {
734
            for (const group of groups) {
735
                const groupIndex = groupsAndOptions.findIndex(option => option.groupLabel === group.thyGroupLabel());
736
                if (groupIndex > -1) {
470✔
737
                    const groupItem: ThySelectFlattedItem = {
738
                        type: 'group',
739
                        label: group.thyGroupLabel(),
740
                        disabled: group.thyDisabled()
2,247✔
741
                    };
742
                    groupsAndOptions.splice(groupIndex, 0, groupItem);
743
                }
744
            }
80✔
745
        }
746

747
        return groupsAndOptions;
748
    }
1!
749

1✔
750
    private buildFilteredGroupsAndOptions() {
751
        const keywords = this.keywords();
752
        const isServerSearch = this.thyServerSearch();
753
        const allGroupsAndOptions = this.allGroupsAndOptions();
90✔
754
        const filteredGroupsAndOptions: ThySelectFlattedItem[] = [];
10✔
755

9✔
756
        if (keywords && !isServerSearch) {
757
            const lowerKeywords = keywords.toLowerCase();
758

80✔
759
            const matchedOptions = new Set<string | number>();
760
            const matchedGroupLabels = new Set<string>();
761

762
            for (const item of allGroupsAndOptions) {
763
                if (item.type === 'option') {
83✔
764
                    const isMatch =
2✔
765
                        (item.searchKey || item.label) && (item.searchKey || item.label)!.toLowerCase().indexOf(lowerKeywords) > -1;
766
                    if (isMatch) {
81✔
767
                        matchedOptions.add(item.value!);
81✔
768
                        if (item.groupLabel) {
81✔
769
                            matchedGroupLabels.add(item.groupLabel);
81✔
770
                        }
81✔
771
                    }
81✔
772
                }
773
            }
774

775
            for (const item of allGroupsAndOptions) {
57✔
776
                if (item.type === 'group' && matchedGroupLabels.has(item.label!)) {
30✔
777
                    filteredGroupsAndOptions.push(item);
30✔
778
                } else if (item.type === 'option' && matchedOptions.has(item.value!)) {
30✔
779
                    filteredGroupsAndOptions.push(item);
30✔
780
                }
30✔
781
            }
782

783
            return filteredGroupsAndOptions;
784
        }
785

47✔
786
        return allGroupsAndOptions;
47✔
787
    }
66✔
788

789
    private updateSelectedOptions() {
47✔
790
        const selectedValues = this.selectedValues();
25✔
791
        const isMultiple = untracked(() => this.isMultiple());
792
        const newOptions: SelectOptionBase[] = [];
22✔
793

2✔
794
        if (selectedValues.length) {
795
            const filteredOptionsMap = this.filteredOptionsMap();
20✔
796
            const oldSelectedOptionsMap = untracked(() => {
797
                const selected = this.selectedOptions();
798
                let oldSelectedOptions: SelectOptionBase[];
47✔
799
                if (helpers.isArray(selected)) {
47✔
800
                    oldSelectedOptions = selected;
801
                } else if (selected) {
802
                    oldSelectedOptions = [selected];
81✔
803
                } else {
91✔
804
                    oldSelectedOptions = [];
91✔
805
                }
81✔
806
                return helpers.keyBy(oldSelectedOptions, 'thyValue');
5✔
807
            });
808

76✔
809
            selectedValues.forEach(value => {
75✔
810
                const option: ThySelectFlattedItem | undefined = filteredOptionsMap.get(value);
1✔
811

812
                if (option) {
74✔
813
                    newOptions.push({
814
                        thyLabelText: option.label!,
1✔
815
                        thyValue: option.value,
816
                        thyRawValue: option.rawValue
817
                    });
10!
818
                } else if (oldSelectedOptionsMap[value]) {
×
819
                    newOptions.push(oldSelectedOptionsMap[value]);
820
                }
821
            });
10✔
822

823
            this.selectedOptions.set(isMultiple ? newOptions : newOptions.length ? newOptions[0] : null);
824
        } else {
825
            this.selectedOptions.set(isMultiple ? [] : null);
826
        }
827
    }
179✔
828

2✔
829
    public optionsVirtualScrolled(index: number) {
830
        this.scrolledIndex = index;
179✔
831

832
        if (this.thyEnableScrollLoad()) {
833
            const isScrollToBottom = index + this.maxItemLength() >= this.filteredGroupsAndOptions().length;
834
            if (isScrollToBottom) {
835
                this.thyOnScrollToBottom.emit();
836
            }
179✔
837
        }
×
838
    }
×
839

840
    public optionsScrolled(elementRef: ElementRef) {
179✔
841
        const scroll = elementRef.nativeElement.scrollTop,
90✔
842
            height = elementRef.nativeElement.clientHeight,
19✔
843
            scrollHeight = elementRef.nativeElement.scrollHeight;
18✔
844

845
        if (scroll + height + 10 >= scrollHeight) {
71✔
846
            this.ngZone.run(() => {
5✔
847
                this.thyOnScrollToBottom.emit();
848
            });
849
        }
850
    }
851

852
    public search(keywords: string) {
4✔
853
        this.shouldActivateOption = true;
4✔
854
        this.activatedValue.set(null);
4✔
855
        this.keywords.set(keywords.trim());
4✔
856

857
        if (this.thyServerSearch()) {
858
            this.thyOnSearch.emit(keywords);
4!
859
        } else {
1✔
860
            this.updateCdkConnectedOverlayPositions();
1✔
861
        }
3✔
862
    }
3✔
863

2✔
864
    onBlur(event?: FocusEvent) {
2✔
865
        // Tab 聚焦后自动聚焦到 input 输入框,此分支下直接返回,无需触发 onTouchedFn
866
        if (elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-select-dropdown', 'thy-select'])) {
1✔
867
            return;
868
        }
869
        this.onTouchedFn();
870
    }
871

872
    onFocus(event?: FocusEvent) {
13✔
873
        // thyShowSearch 与 panelOpen 均为 true 时,点击 thySelectControl 需要触发自动聚焦到 input 的逻辑
13✔
874
        // manualFocusing 如果是手动聚焦,不触发自动聚焦到 input 的逻辑
13✔
875
        if (
876
            (this.thyShowSearch() && this.panelOpen) ||
13✔
877
            (!this.manualFocusing &&
2✔
878
                !elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-select-dropdown', 'thy-custom-select']))
2✔
879
        ) {
11✔
880
            const inputElement: HTMLInputElement = this.elementRef.nativeElement.querySelector('input');
881
            inputElement.focus();
1✔
882
        }
1✔
883
        this.manualFocusing = false;
10✔
884
    }
3✔
885

3!
886
    public focus(options?: FocusOptions): void {
×
887
        this.manualFocusing = true;
×
888
        this.elementRef.nativeElement.focus(options);
889
        this.manualFocusing = false;
890
    }
3✔
891

7✔
892
    public remove($event: { item: SelectOptionBase; $eventOrigin: Event }) {
1✔
893
        $event.$eventOrigin.stopPropagation();
1✔
894
        if (this.disabled) {
895
            return;
1✔
896
        }
8✔
897
        const selectedValue = this.selectedValues();
7!
898
        const index = selectedValue.indexOf($event.item.thyValue);
899
        if (index > -1) {
900
            this.selectedValues.set([...selectedValue.slice(0, index), ...selectedValue.slice(index + 1)]);
901
        }
6!
902
        this.emitModelValueChange();
×
903
    }
904

6✔
905
    public clearSelectValue(event?: Event) {
906
        if (event) {
6✔
907
            event.stopPropagation();
908
        }
6!
909
        if (this.disabled) {
6!
910
            return;
911
        }
912
        this.selectedValues.set([]);
913
        this.emitModelValueChange();
914
    }
915

×
916
    public updateCdkConnectedOverlayPositions(): void {
917
        setTimeout(() => {
918
            if (this.cdkConnectedOverlay() && this.cdkConnectedOverlay()!.overlayRef) {
919
                this.cdkConnectedOverlay()!.overlayRef.updatePosition();
920
            }
921
        });
367✔
922
    }
923

924
    readonly isMultiple = computed<boolean>(() => {
925
        return this.thyMode() === 'multiple';
350✔
926
    });
179✔
927

928
    readonly empty = computed(() => {
350✔
929
        return !this.selectedValues().length;
350✔
930
    });
179✔
931

179✔
932
    public activatedValue: WritableSignal<string | string[] | number | null | undefined> = signal(null);
933

350✔
934
    public toggle(event: MouseEvent): void {
101✔
935
        if (this.panelOpen) {
101✔
936
            if (!this.thyShowSearch()) {
937
                this.close();
938
            }
939
        } else {
940
            this.open();
179✔
941
        }
942
    }
179✔
943

104✔
944
    public open(): void {
104✔
945
        if (this.disabled || this.panelOpen) {
14✔
946
            return;
14✔
947
        }
948
        this.triggerRectWidth.set(this.getOriginRectWidth());
949
        this.subscribeTriggerResize();
950
        this.panelOpen = true;
951
        this.shouldActivateOption = true;
952
        this.thyOnExpandStatusChange.emit(this.panelOpen);
179✔
953
        this.changeDetectorRef.markForCheck();
179✔
954
    }
955

956
    public close(): void {
957
        if (this.panelOpen) {
958
            this.panelOpen = false;
367✔
959
            this.scrolledIndex = 0;
367✔
960
            this.unsubscribeTriggerResize();
24✔
961
            this.thyOnExpandStatusChange.emit(this.panelOpen);
962
            this.changeDetectorRef.markForCheck();
343✔
963
            this.onTouchedFn();
964
        }
367✔
965
    }
966

967
    private emitModelValueChange() {
968
        let modelValue: SafeAny;
969
        const selectedValues = this.selectedValues();
970
        if (this.isMultiple()) {
971
            modelValue = selectedValues;
426✔
972
        } else {
236✔
973
            if (selectedValues.length === 0) {
1✔
974
                modelValue = null;
1✔
975
            } else {
976
                modelValue = selectedValues[0];
236✔
977
            }
978
        }
190✔
979
        this.onChangeFn(modelValue);
28✔
980
        this.updateCdkConnectedOverlayPositions();
28✔
981
    }
28✔
982

28✔
983
    private scrollToActivatedOption(needSelect: boolean = false): void {
984
        if (!this.panelOpen) {
185✔
985
            return;
2✔
986
        }
25✔
987

22✔
988
        const filteredOptions = this.filteredOptions();
989
        if (!filteredOptions.length) {
990
            return;
991
        }
992

162✔
993
        let toActivatedValue: string | string[] | number | null | undefined = this.activatedValue();
196✔
994
        const filteredOptionsMap = this.filteredOptionsMap();
995
        if (!toActivatedValue || !filteredOptionsMap.has(toActivatedValue)) {
162✔
996
            let selectedValues = this.selectedValues();
135✔
997

998
            const lowerKeywords = this.keywords()?.trim()?.toLowerCase();
999
            if (lowerKeywords) {
190✔
1000
                selectedValues = selectedValues.filter(value => {
1001
                    const option = filteredOptionsMap.get(value);
1002
                    return (
1003
                        option &&
105✔
1004
                        (option.searchKey || option.label) &&
1005
                        (option.searchKey || option.label)!.toLowerCase().indexOf(lowerKeywords) > -1
105!
1006
                    );
×
1007
                });
×
1008
            }
1009

105✔
1010
            if (selectedValues.length > 0) {
44✔
1011
                toActivatedValue = selectedValues[0];
1012
            } else {
1013
                if (this.thyAutoActiveFirstItem()) {
105✔
1014
                    toActivatedValue = filteredOptions[0].value || null;
33✔
1015
                }
1016
            }
1017

105✔
1018
            if (!toActivatedValue) {
56✔
1019
                return;
56✔
1020
            }
12✔
1021
            this.activatedValue.set(toActivatedValue);
1022
        }
1023

1024
        const targetIndex = this.filteredGroupsAndOptions().findIndex(item => item.value === toActivatedValue);
1025
        if (targetIndex === -1) {
105✔
1026
            return;
44✔
1027
        }
1028

105✔
1029
        if (this.thyVirtualScroll()) {
49✔
1030
            if (targetIndex < this.scrolledIndex || targetIndex >= this.scrolledIndex + this.maxItemLength()) {
1031
                this.cdkVirtualScrollViewport()?.scrollToIndex(targetIndex || 0);
105✔
1032
            }
1033
        } else {
1034
            const panelElement = this.panel()?.nativeElement;
1035
            if (panelElement) {
56✔
1036
                const optionElement = panelElement.querySelector(`[data-option-value="${toActivatedValue}"]`) as HTMLElement;
56✔
1037
                if (optionElement) {
1038
                    ScrollToService.scrollToElement(optionElement, panelElement);
56✔
1039
                }
2✔
1040
            }
1✔
1041
        }
1042

1043
        if (needSelect) {
1044
            this.optionRenders.find(option => option.thyValue() === toActivatedValue)?.selectViaInteraction();
1045
        }
1046
    }
1047

81✔
1048
    private handleKeydown(event: KeyboardEvent): void {
1049
        const keyCode = event.keyCode;
1050
        const isOpenKey = keyCode === ENTER || keyCode === SPACE;
1051
        const isArrowKey = keyCode === DOWN_ARROW || keyCode === UP_ARROW;
81✔
1052

81✔
1053
        if (event.altKey) {
81✔
1054
            if (this.panelOpen && isArrowKey) {
81✔
1055
                event.preventDefault();
×
1056
                this.close();
1057
                return;
81✔
1058
            }
1059
        }
1060

1061
        if (!this.panelOpen) {
1062
            this.open();
×
1063
            if (isOpenKey) {
1064
                event.preventDefault();
1065
                return;
1066
            }
1067
        }
×
1068

×
1069
        const filteredOptions = this.filteredOptions();
×
1070
        if (keyCode === DOWN_ARROW || keyCode === UP_ARROW) {
×
1071
            event.preventDefault();
1072

1073
            const activatedValue = this.activatedValue();
1074
            const currentOption = this.filteredOptionsMap().get(activatedValue);
1075
            if (!currentOption) {
1076
                return;
1077
            }
283✔
1078

81✔
1079
            const currentIndex = filteredOptions.indexOf(currentOption);
81✔
1080
            let targetIndex: number;
1081
            if (keyCode === DOWN_ARROW) {
1082
                targetIndex = currentIndex + 1;
1083
                if (targetIndex > filteredOptions.length - 1) {
1084
                    targetIndex = 0;
172✔
1085
                }
172✔
1086
                let attempts = 0;
172✔
1087
                while (filteredOptions[targetIndex]?.disabled && attempts < filteredOptions.length) {
1088
                    targetIndex++;
1089
                    if (targetIndex > filteredOptions.length - 1) {
1090
                        targetIndex = 0;
1091
                    }
1092
                    attempts++;
1093
                }
1094
            } else {
1095
                targetIndex = currentIndex - 1;
1096
                if (targetIndex < 0) {
1097
                    targetIndex = filteredOptions.length - 1;
1098
                }
1099
                let attempts = 0;
1100
                while (filteredOptions[targetIndex]?.disabled && attempts < filteredOptions.length) {
1101
                    targetIndex--;
1102
                    if (targetIndex < 0) {
1103
                        targetIndex = filteredOptions.length - 1;
1104
                    }
1105
                    attempts++;
1106
                }
1107
            }
1108

1109
            const targetOption = filteredOptions[targetIndex];
1110
            if (targetOption?.disabled) {
1111
                return;
1112
            }
1113

1114
            this.activatedValue.set(targetOption.value);
1115

1116
            if (!hasModifierKey(event)) {
1117
                this.scrollToActivatedOption();
1118
            } else if (this.isMultiple() && event.shiftKey) {
1119
                this.scrollToActivatedOption(true);
1120
            }
1121
        } else if (keyCode === HOME || keyCode === END) {
1122
            event.preventDefault();
1123
            const targetOption = keyCode === HOME ? filteredOptions[0] : filteredOptions[filteredOptions.length - 1];
1124

1125
            this.activatedValue.set(targetOption.value);
1126
            this.scrollToActivatedOption();
1127
        } else if ((keyCode === ENTER || keyCode === SPACE) && (this.activatedValue() || !this.empty()) && !hasModifierKey(event)) {
1128
            event.preventDefault();
1129
            this.scrollToActivatedOption(true);
1130
        } else if (this.isMultiple() && keyCode === A && event.ctrlKey) {
1131
            event.preventDefault();
1132
            const hasDeselectedOptions = filteredOptions.some(opt => !opt.disabled && !this.selectedValues().includes(opt.value));
1133
            let selectedValues: SafeAny[] = [];
1134
            if (hasDeselectedOptions) {
1135
                selectedValues = filteredOptions.filter(option => !option.disabled).map(option => option.value);
1136
            }
1137
            this.selectedValues.set(selectedValues);
1138
            this.emitModelValueChange();
1139
        } else if (keyCode === TAB) {
1140
            this.focus();
1141
            this.close();
1142
        }
1143
    }
1144

1145
    optionClick(event: { value: SafeAny; isUserInput: boolean }) {
1146
        const { value, isUserInput } = event;
1147
        const options = this.options();
1148

1149
        if (this.isMultiple()) {
1150
            const selectedValues = [...(this.selectedValues() || [])];
1151
            const index = selectedValues.indexOf(value);
1152
            if (index > -1) {
1153
                selectedValues.splice(index, 1);
1154
            } else {
1155
                selectedValues.push(value);
1156
            }
1157
            const thySortComparator = this.thySortComparator();
1158
            if (thySortComparator) {
1159
                selectedValues.sort((a: SafeAny, b: SafeAny) => {
1160
                    const aOption = options.find(option => option.thyValue() === a)!;
1161
                    const bOption = options.find(option => option.thyValue() === b)!;
1162
                    return thySortComparator(aOption, bOption, [...options]);
1163
                });
1164
            }
1165
            this.selectedValues.set(selectedValues);
1166
        } else {
1167
            this.selectedValues.set([value]);
1168
        }
1169

1170
        const option = options.find(option => option.thyValue() === value);
1171
        if (option) {
1172
            const selected = this.selectedValues().includes(value);
1173
            option.selected.set(selected);
1174
            option.selectionChange.emit({ option, isUserInput });
1175
        }
1176

1177
        this.emitModelValueChange();
1178
        if (!this.isMultiple()) {
1179
            this.onTouchedFn();
1180
            this.close();
1181
        }
1182
    }
1183

1184
    optionHover(value: SafeAny) {
1185
        this.activatedValue.set(value);
1186
    }
1187

1188
    mouseLeaveOptions() {
1189
        this.activatedValue.set(null);
1190
    }
1191

1192
    private getOriginRectWidth() {
1193
        return this.thyOrigin() ? coerceElement(this.thyOrigin()).offsetWidth : this.trigger.nativeElement.offsetWidth;
1194
    }
1195

1196
    private subscribeTriggerResize(): void {
1197
        this.unsubscribeTriggerResize();
1198
        this.ngZone.runOutsideAngular(() => {
1199
            this.resizeSubscription = new Observable<number | null>(observer => {
1200
                const resize = new ResizeObserver((entries: ResizeObserverEntry[]) => {
1201
                    observer.next(null);
1202
                });
1203
                resize.observe(this.trigger.nativeElement);
1204
                return () => {
1205
                    resize.disconnect();
1206
                };
1207
            })
1208
                .pipe(
1209
                    startWith(),
1210
                    map(() => {
1211
                        return this.getOriginRectWidth();
1212
                    }),
1213
                    distinctUntilChanged()
1214
                )
1215
                .subscribe((width: number) => {
1216
                    this.ngZone.run(() => {
1217
                        this.triggerRectWidth.set(width);
1218
                        this.updateCdkConnectedOverlayPositions();
1219
                        this.changeDetectorRef.markForCheck();
1220
                    });
1221
                });
1222
        });
1223
    }
1224

1225
    private unsubscribeTriggerResize(): void {
1226
        if (this.resizeSubscription) {
1227
            this.resizeSubscription.unsubscribe();
1228
            this.resizeSubscription = null;
1229
        }
1230
    }
1231

1232
    trackByFn(index: number, item: ThySelectFlattedItem): SafeAny {
1233
        if (item.type === 'group') {
1234
            return item.label || index;
1235
        }
1236
        if (item.type === 'option') {
1237
            return item.value || index;
1238
        }
1239
    }
1240

1241
    ngOnDestroy() {
1242
        this.unsubscribeTriggerResize();
1243
    }
1244
}
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

© 2026 Coveralls, Inc