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

atinc / ngx-tethys / a17780e0-3ed6-4063-bafc-db3a3d351588

18 Nov 2024 08:52AM UTC coverage: 90.356% (+0.006%) from 90.35%
a17780e0-3ed6-4063-bafc-db3a3d351588

push

circleci

web-flow
feat(select): support i18n #TINFR-963 (#3250)

5522 of 6760 branches covered (81.69%)

Branch coverage included in aggregate %.

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

2 existing lines in 1 file now uncovered.

13207 of 13968 relevant lines covered (94.55%)

996.08 hits per line

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

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

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

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

89
export type SelectMode = 'multiple' | '';
UNCOV
90

×
91
export type ThySelectTriggerType = 'click' | 'hover';
92

93
export const SELECT_PANEL_MAX_HEIGHT = 300;
94

1,520✔
95
export const SELECT_OPTION_MAX_HEIGHT = 40;
96

97
export const SELECT_OPTION_GROUP_MAX_HEIGHT = 30;
172✔
98

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

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

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

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

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

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

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

1✔
182
    disabled = false;
183

184
    size: SelectControlSize;
185

1✔
186
    mode: SelectMode = '';
187

1✔
188
    emptyStateText = this.locale().empty;
3✔
189

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

1✔
192
    scrollTop = 0;
1✔
193

194
    modalValue: any = null;
195

196
    defaultOffset = 4;
1✔
197

198
    dropDownClass: { [key: string]: boolean };
199

1✔
200
    dropDownMinWidth: number | null = null;
201

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

208
    public dropDownPositions: ConnectionPositionPair[];
209

210
    public selectionModel: SelectionModel<ThyOption>;
188✔
211

188✔
212
    public triggerRectWidth: number;
188✔
213

1✔
214
    public scrollStrategy: ScrollStrategy;
215

187✔
216
    private resizeSubscription: Subscription;
4✔
217

218
    private selectionModelSubscription: Subscription;
219

183✔
220
    /**
221
     * 手动聚焦中的标识
188✔
222
     */
223
    private manualFocusing = false;
224

171✔
225
    private config: ThySelectConfig;
3✔
226

227
    private readonly destroy$ = new Subject<void>();
228

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

179✔
239
    @ViewChild(CdkConnectedOverlay, { static: true }) cdkConnectedOverlay: CdkConnectedOverlay;
1✔
240

1✔
241
    @HostBinding('class.thy-select-custom') isSelectCustom = true;
242

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

245
    keyManager: ActiveDescendantKeyManager<ThyOption>;
246

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

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

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

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

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

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

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

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

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

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

52✔
304
    get thyMode(): SelectMode {
12✔
305
        return this.mode;
306
    }
307

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

25✔
419
    isReactiveDriven = false;
420

421
    innerOptions: ThySelectOptionModel[];
22✔
422

2✔
423
    optionGroups: ThyOptionGroupModel[] = [];
424

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

75✔
439
    options: QueryList<ThyOption>;
1✔
440

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

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

10!
449
    @ViewChild('panel', { read: ElementRef }) panel: ElementRef<HTMLElement>;
×
450

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

456
    @ViewChildren(ThyOption) viewOptions: QueryList<ThyOption>;
457

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

463
    @ViewChildren(ThySelectOptionGroup) viewGroups: QueryList<ThySelectOptionGroup>;
464

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

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

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

498
    private isSearching = false;
499

500
    groupBy = (item: ThySelectOptionModel) => item.groupLabel;
501

13✔
502
    get placement(): ThyPlacement {
13✔
503
        return this.thyPlacement || this.config.placement;
13✔
504
    }
13✔
505

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

510
        this.config = { ...DEFAULT_SELECT_CONFIG, ...selectConfig };
1✔
511
        this.buildScrollStrategy();
1✔
512
    }
513

10✔
514
    writeValue(value: any): void {
3✔
515
        this.modalValue = value;
3!
516
        this.setSelectionByModelValue(this.modalValue);
×
517
    }
×
518

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

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

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

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

179✔
576
    getDropdownMinWidth(): number | null {
577
        const mode = this.thyDropdownWidthMode || this.config.dropdownWidthMode;
578
        let dropdownMinWidth: number | null = null;
579

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

588
        return dropdownMinWidth;
589
    }
590

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

924
            manager.onKeydown(event);
925

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

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

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

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

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

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

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

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

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

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

1031
            if (isUserInput) {
1032
                this.keyManager.setActiveItem(option);
1033
            }
1034

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

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

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

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

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

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

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

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