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

atinc / ngx-tethys / 68ef226c-f83e-44c1-b8ed-e420a83c5d84

28 May 2025 10:31AM UTC coverage: 10.352% (-80.0%) from 90.316%
68ef226c-f83e-44c1-b8ed-e420a83c5d84

Pull #3460

circleci

pubuzhixing8
chore: xxx
Pull Request #3460: refactor(icon): migrate signal input #TINFR-1476

132 of 6823 branches covered (1.93%)

Branch coverage included in aggregate %.

10 of 14 new or added lines in 1 file covered. (71.43%)

11648 existing lines in 344 files now uncovered.

2078 of 14525 relevant lines covered (14.31%)

6.69 hits per line

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

1.0
/src/cascader/cascader.component.ts
1
import { CdkConnectedOverlay, CdkOverlayOrigin, ConnectedOverlayPositionChange, ConnectionPositionPair } from '@angular/cdk/overlay';
2
import { isPlatformBrowser, NgClass, NgStyle, NgTemplateOutlet } from '@angular/common';
3
import {
4
    AfterContentInit,
5
    ChangeDetectorRef,
6
    Component,
7
    ElementRef,
8
    forwardRef,
9
    HostListener,
10
    Input,
11
    NgZone,
12
    numberAttribute,
13
    OnDestroy,
14
    OnInit,
15
    PLATFORM_ID,
16
    TemplateRef,
17
    inject,
18
    Signal,
19
    input,
20
    computed,
21
    output,
22
    viewChild,
23
    effect,
24
    viewChildren
25
} from '@angular/core';
1✔
26
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
UNCOV
27
import { useHostRenderer } from '@tethys/cdk/dom';
×
28
import {
29
    EXPANDED_DROPDOWN_POSITIONS,
UNCOV
30
    DebounceTimeWrapper,
×
31
    ScrollToService,
32
    TabIndexDisabledControlValueAccessorMixin,
UNCOV
33
    ThyClickDispatcher,
×
34
    injectPanelEmptyIcon
35
} from 'ngx-tethys/core';
UNCOV
36
import { ThyEmpty } from 'ngx-tethys/empty';
×
37
import { SelectControlSize, SelectOptionBase, ThySelectControl } from 'ngx-tethys/shared';
38
import { SafeAny } from 'ngx-tethys/types';
39
import { coerceBooleanProperty, elementMatchClosest, isEmpty } from 'ngx-tethys/util';
×
40
import { BehaviorSubject, Observable, Subject, Subscription, timer } from 'rxjs';
41
import { distinctUntilChanged, filter, take, takeUntil } from 'rxjs/operators';
UNCOV
42
import { scaleYMotion } from 'ngx-tethys/core';
×
43
import { ThyDivider } from 'ngx-tethys/divider';
44
import { ThyCascaderOptionComponent } from './cascader-li.component';
UNCOV
45
import { ThyCascaderSearchOptionComponent } from './cascader-search-option.component';
×
UNCOV
46
import { ThyCascaderService } from './cascader.service';
×
UNCOV
47
import { ThyCascaderExpandTrigger, ThyCascaderOption, ThyCascaderSearchOption, ThyCascaderTriggerType } from './types';
×
UNCOV
48
import { injectLocale, ThyCascaderLocale } from 'ngx-tethys/i18n';
×
UNCOV
49

×
UNCOV
50
/**
×
UNCOV
51
 * 级联选择菜单
×
UNCOV
52
 * @name thy-cascader
×
UNCOV
53
 */
×
UNCOV
54
@Component({
×
UNCOV
55
    selector: 'thy-cascader,[thy-cascader]',
×
UNCOV
56
    templateUrl: './cascader.component.html',
×
UNCOV
57
    providers: [
×
UNCOV
58
        {
×
UNCOV
59
            provide: NG_VALUE_ACCESSOR,
×
UNCOV
60
            useExisting: forwardRef(() => ThyCascader),
×
UNCOV
61
            multi: true
×
UNCOV
62
        },
×
UNCOV
63
        ThyCascaderService
×
UNCOV
64
    ],
×
UNCOV
65
    host: {
×
UNCOV
66
        '[attr.tabindex]': `tabIndex`,
×
67
        '(focus)': 'onFocus($event)',
UNCOV
68
        '(blur)': 'onBlur($event)'
×
UNCOV
69
    },
×
UNCOV
70
    imports: [
×
UNCOV
71
        CdkOverlayOrigin,
×
UNCOV
72
        ThySelectControl,
×
UNCOV
73
        NgClass,
×
UNCOV
74
        NgTemplateOutlet,
×
UNCOV
75
        CdkConnectedOverlay,
×
UNCOV
76
        NgStyle,
×
77
        ThyCascaderOptionComponent,
78
        ThyCascaderSearchOptionComponent,
79
        ThyEmpty,
80
        ThyDivider
UNCOV
81
    ],
×
UNCOV
82
    animations: [scaleYMotion]
×
UNCOV
83
})
×
UNCOV
84
export class ThyCascader
×
UNCOV
85
    extends TabIndexDisabledControlValueAccessorMixin
×
UNCOV
86
    implements ControlValueAccessor, OnInit, OnDestroy, AfterContentInit
×
UNCOV
87
{
×
UNCOV
88
    private platformId = inject(PLATFORM_ID);
×
UNCOV
89
    private cdr = inject(ChangeDetectorRef);
×
UNCOV
90
    elementRef = inject(ElementRef);
×
UNCOV
91
    private thyClickDispatcher = inject(ThyClickDispatcher);
×
UNCOV
92
    private ngZone = inject(NgZone);
×
UNCOV
93
    thyCascaderService = inject(ThyCascaderService);
×
UNCOV
94
    private locale: Signal<ThyCascaderLocale> = injectLocale('cascader');
×
UNCOV
95
    emptyIcon: Signal<string> = injectPanelEmptyIcon();
×
UNCOV
96

×
UNCOV
97
    /**
×
UNCOV
98
     * 选项的实际值的属性名
×
UNCOV
99
     */
×
UNCOV
100
    readonly thyValueProperty = input('value');
×
UNCOV
101

×
UNCOV
102
    /**
×
UNCOV
103
     * 选项的显示值的属性名
×
UNCOV
104
     */
×
UNCOV
105
    readonly thyLabelProperty = input('label');
×
UNCOV
106

×
UNCOV
107
    /**
×
UNCOV
108
     * 描述输入字段预期值的简短的提示信息
×
UNCOV
109
     */
×
110
    readonly thyPlaceholder = input(this.locale().placeholder);
111

112
    /**
113
     * 控制大小(5种)
114
     * @type 'xs' | 'sm' | 'md' | 'lg' | ''
115
     */
UNCOV
116
    readonly thySize = input<SelectControlSize>('');
×
UNCOV
117

×
UNCOV
118
    /**
×
UNCOV
119
     * 数据项
×
UNCOV
120
     * @type ThyCascaderOption[]
×
UNCOV
121
     */
×
UNCOV
122
    readonly thyOptions = input<ThyCascaderOption[] | null>([]);
×
123

124
    /**
UNCOV
125
     * 自定义选项
×
UNCOV
126
     * @type ThyCascaderOption[]
×
UNCOV
127
     */
×
UNCOV
128
    readonly thyCustomOptions = input<ThyCascaderOption[] | null>([]);
×
129

130
    /**
UNCOV
131
     * 点击父级菜单选项时,可通过该函数判断是否允许值的变化
×
UNCOV
132
     */
×
UNCOV
133
    readonly thyChangeOn = input<(option: ThyCascaderOption, level: number) => boolean>();
×
UNCOV
134

×
UNCOV
135
    /**
×
UNCOV
136
     * 点击项时,表单是否动态展示数据项
×
137
     * @type boolean
138
     */
UNCOV
139
    readonly thyChangeOnSelect = input(false, { transform: coerceBooleanProperty });
×
UNCOV
140

×
141
    /**
UNCOV
142
     * 显示输入框
×
UNCOV
143
     * @type boolean
×
144
     */
UNCOV
145
    readonly thyShowInput = input(true, { transform: coerceBooleanProperty });
×
UNCOV
146

×
147
    /**
148
     * 用户自定义选项模板
149
     * @type TemplateRef
UNCOV
150
     */
×
UNCOV
151
    readonly thyOptionRender = input<TemplateRef<SafeAny>>();
×
UNCOV
152

×
UNCOV
153
    /**
×
UNCOV
154
     * 用户自定义模板
×
UNCOV
155
     * @type TemplateRef
×
UNCOV
156
     */
×
UNCOV
157
    readonly thyLabelRender = input<TemplateRef<any>>();
×
UNCOV
158

×
UNCOV
159
    readonly isLabelRenderTemplate: Signal<boolean> = computed(() => {
×
160
        return this.thyLabelRender() instanceof TemplateRef;
UNCOV
161
    });
×
UNCOV
162

×
UNCOV
163
    /**
×
UNCOV
164
    /**
×
UNCOV
165
     * 用于动态加载选项
×
166
     */
167
    readonly thyLoadData = input<(node: ThyCascaderOption, index?: number) => PromiseLike<any>>();
168

UNCOV
169
    /**
×
UNCOV
170
     * 控制触发状态, 支持 `click` | `hover`
×
171
     * @type ThyCascaderTriggerType | ThyCascaderTriggerType[]
172
     */
173
    readonly thyTriggerAction = input<ThyCascaderTriggerType | ThyCascaderTriggerType[]>(['click']);
UNCOV
174

×
175
    /**
176
     * 鼠标经过下方列表项时,是否自动展开列表,支持 `click` | `hover`
UNCOV
177
     * @type ThyCascaderExpandTrigger | ThyCascaderExpandTrigger[]
×
UNCOV
178
     */
×
UNCOV
179
    readonly thyExpandTriggerAction = input<ThyCascaderExpandTrigger | ThyCascaderExpandTrigger[]>(['click']);
×
180

181
    /**
182
     * 自定义浮层样式
183
     */
184
    readonly thyMenuStyle = input<{
185
        [key: string]: string;
UNCOV
186
    }>();
×
UNCOV
187

×
UNCOV
188
    /**
×
UNCOV
189
     * 自定义搜索样式
×
190
     */
191
    readonly thySearchListStyle = input<{
192
        [key: string]: string;
193
    }>();
UNCOV
194

×
195
    /**
196
     * 自定义浮层类名
197
     * @type string
198
     */
199
    readonly thyMenuClassName = input<string>();
200

201
    /**
UNCOV
202
     * 自定义浮层列类名
×
203
     * @type string
204
     */
UNCOV
205
    readonly thyColumnClassName = input<string>();
×
UNCOV
206

×
207
    readonly menuColumnCls = computed(() => {
UNCOV
208
        return {
×
209
            [`${this.prefixCls}-menu`]: true,
210
            [`${this.thyColumnClassName()}`]: this.thyColumnClassName()
UNCOV
211
        };
×
UNCOV
212
    });
×
UNCOV
213

×
214
    /**
215
     * 是否只读
216
     * @default false
UNCOV
217
     */
×
218
    @Input({ transform: coerceBooleanProperty })
219
    override set thyDisabled(value: boolean) {
UNCOV
220
        this.disabled = value;
×
UNCOV
221
    }
×
UNCOV
222
    override get thyDisabled(): boolean {
×
UNCOV
223
        return this.disabled;
×
224
    }
225

226
    disabled = false;
UNCOV
227

×
228
    /**
229
     * 空状态下的展示文字
UNCOV
230
     * @default 暂无可选项
×
231
     */
232

UNCOV
233
    readonly thyEmptyStateText = input<string>();
×
234

235
    /**
UNCOV
236
     * 是否多选
×
UNCOV
237
     * @type boolean
×
238
     */
239
    readonly thyMultiple = input(false, { transform: coerceBooleanProperty });
UNCOV
240

×
241
    /**
242
     * 设置多选时最大显示的标签数量,0 表示不限制
243
     * @type number
UNCOV
244
     */
×
UNCOV
245
    readonly thyMaxTagCount = input(0, { transform: numberAttribute });
×
UNCOV
246

×
247
    /**
248
     * 是否仅允许选择叶子项
UNCOV
249
     * @default true
×
UNCOV
250
     */
×
UNCOV
251
    readonly thyIsOnlySelectLeaf = input(true, { transform: coerceBooleanProperty });
×
UNCOV
252

×
253
    /**
254
     * 初始化时,是否展开面板
255
     */
256
    readonly thyAutoExpand = input(false, { transform: coerceBooleanProperty });
257

UNCOV
258
    /**
×
UNCOV
259
     * 是否支持搜索
×
UNCOV
260
     */
×
UNCOV
261
    readonly thyShowSearch = input(false, { transform: coerceBooleanProperty });
×
UNCOV
262

×
UNCOV
263
    /**
×
UNCOV
264
     * 多选选中项的展示方式,默认为空,渲染文字模板,传入tag,渲染展示模板,
×
UNCOV
265
     */
×
266
    readonly thyPreset = input<string>('');
267

UNCOV
268
    /**
×
269
     * 是否有幕布
UNCOV
270
     */
×
271
    readonly thyHasBackdrop = input(true, { transform: coerceBooleanProperty });
272

273
    /**
UNCOV
274
     * 值发生变化时触发,返回选择项的值
×
275
     * @type EventEmitter<any[]>
276
     */
277
    readonly thyChange = output<any[]>();
278

279
    /**
280
     * 值发生变化时触发,返回选择项列表
281
     * @type EventEmitter<ThyCascaderOption[]>
UNCOV
282
     */
×
283
    readonly thySelectionChange = output<ThyCascaderOption[]>();
284

285
    /**
286
     * 选择选项时触发
287
     */
288
    readonly thySelect = output<{
289
        option: ThyCascaderOption;
UNCOV
290
        index: number;
×
291
    }>();
292

293
    /**
294
     * @private 暂无实现
295
     */
296
    readonly thyDeselect = output<{
UNCOV
297
        option: ThyCascaderOption;
×
298
        index: number;
299
    }>();
UNCOV
300

×
UNCOV
301
    /**
×
UNCOV
302
     * 清空选项时触发
×
303
     */
UNCOV
304
    readonly thyClear = output<void>();
×
305

306
    /**
UNCOV
307
     * 下拉选项展开和折叠状态事件
×
UNCOV
308
     */
×
UNCOV
309
    readonly thyExpandStatusChange = output<boolean>();
×
310

311
    readonly cascaderOptions = viewChildren<ThyCascaderOptionComponent, ElementRef>('cascaderOptions', { read: ElementRef });
×
312

313
    readonly cascaderOptionContainers = viewChildren('cascaderOptionContainers', { read: ElementRef });
UNCOV
314

×
UNCOV
315
    readonly cdkConnectedOverlay = viewChild<CdkConnectedOverlay>(CdkConnectedOverlay);
×
UNCOV
316

×
317
    readonly trigger = viewChild<ElementRef<any>>('trigger');
318

×
319
    readonly input = viewChild<ElementRef>('input');
320

UNCOV
321
    readonly menu = viewChild<ElementRef>('menu');
×
UNCOV
322

×
323
    public dropDownPosition = 'bottom';
UNCOV
324

×
UNCOV
325
    public menuVisible = false;
×
326

327
    public triggerRect: DOMRect;
328

UNCOV
329
    public menuCls: { [name: string]: any };
×
330

×
331
    public labelCls: { [name: string]: any };
UNCOV
332

×
333
    private prefixCls = 'thy-cascader';
334

UNCOV
335
    private readonly destroy$ = new Subject<void>();
×
UNCOV
336

×
UNCOV
337
    private hostRenderer = useHostRenderer();
×
338

UNCOV
339
    public positions: ConnectionPositionPair[];
×
UNCOV
340

×
UNCOV
341
    get selected(): SelectOptionBase | SelectOptionBase[] {
×
342
        return this.thyMultiple() ? this.thyCascaderService.selectionModel.selected : this.thyCascaderService.selectionModel.selected[0];
343
    }
344

×
345
    public menuMinWidth = 122;
UNCOV
346

×
347
    private searchText$ = new BehaviorSubject('');
348

UNCOV
349
    public get searchResultList(): ThyCascaderSearchOption[] {
×
UNCOV
350
        return this.thyCascaderService.searchResultList;
×
351
    }
UNCOV
352

×
353
    public isShowSearchPanel: boolean = false;
354

UNCOV
355
    /**
×
UNCOV
356
     * 解决搜索&多选的情况下,点击搜索项会导致 panel 闪烁
×
UNCOV
357
     * 由于点击后,thySelectedOptions变化,导致 thySelectControl
×
358
     * 又会触发 searchFilter 函数,即 resetSearch 会执行
UNCOV
359
     * 会导致恢复级联状态再变为搜索状态
×
360
     */
UNCOV
361
    private isSelectingSearchState: boolean = false;
×
UNCOV
362

×
363
    public get isLoading() {
364
        return this.thyCascaderService?.isLoading;
365
    }
UNCOV
366

×
UNCOV
367
    public get columns() {
×
368
        return this.thyCascaderService.columns;
UNCOV
369
    }
×
370

×
371
    private afterChangeFn: () => void;
UNCOV
372

×
UNCOV
373
    private resizeSubscription: Subscription;
×
374

UNCOV
375
    constructor() {
×
376
        super();
377
        effect(() => {
378
            const options = this.thyOptions();
UNCOV
379
            const columns = options && options.length ? [options] : [];
×
380
            this.thyCascaderService.initColumns(columns);
×
381
            if (this.thyCascaderService.defaultValue && columns.length) {
UNCOV
382
                this.thyCascaderService.initOptions(0);
×
383
            }
384
        });
UNCOV
385

×
UNCOV
386
        effect(() => {
×
UNCOV
387
            this.thyCascaderService.customOptions = (this.thyCustomOptions() || []).map(item => ({ ...item }));
×
388
        });
389

390
        effect(() => {
UNCOV
391
            this.setCascaderOptions();
×
UNCOV
392
        });
×
UNCOV
393

×
UNCOV
394
        effect(() => {
×
UNCOV
395
            this.setMenuClass();
×
396
        });
397
    }
398

×
UNCOV
399
    ngOnInit(): void {
×
400
        this.setClassMap();
401
        this.setLabelClass();
UNCOV
402
        this.initPosition();
×
UNCOV
403
        this.initSearch();
×
404
        this.setCascaderOptions();
405
        this.thyCascaderService.cascaderValueChange().subscribe(options => {
406
            if (!options.isValueEqual) {
×
407
                this.onChangeFn(options.value);
×
408
                if (options.isSelectionModelEmpty) {
409
                    this.thyClear.emit();
UNCOV
410
                }
×
UNCOV
411
                this.thySelectionChange.emit(this.thyCascaderService.selectedOptions);
×
UNCOV
412
                this.thyChange.emit(options.value);
×
413
                if (this.afterChangeFn) {
UNCOV
414
                    this.afterChangeFn();
×
UNCOV
415
                    this.afterChangeFn = null;
×
416
                }
UNCOV
417
            }
×
418
        });
419

UNCOV
420
        if (isPlatformBrowser(this.platformId)) {
×
421
            this.thyClickDispatcher
422
                .clicked(0)
UNCOV
423
                .pipe(takeUntil(this.destroy$))
×
UNCOV
424
                .subscribe(event => {
×
425
                    if (
UNCOV
426
                        !this.elementRef.nativeElement.contains(event.target) &&
×
427
                        !this.menu()?.nativeElement.contains(event.target as Node) &&
428
                        this.menuVisible
UNCOV
429
                    ) {
×
UNCOV
430
                        this.ngZone.run(() => {
×
431
                            this.closeMenu();
UNCOV
432
                            this.cdr.markForCheck();
×
433
                        });
UNCOV
434
                    }
×
UNCOV
435
                });
×
UNCOV
436
        }
×
437
    }
438

439
    ngAfterContentInit() {
UNCOV
440
        if (this.thyAutoExpand()) {
×
441
            timer(0).subscribe(() => {
442
                this.cdr.markForCheck();
UNCOV
443
                this.setMenuVisible(true);
×
UNCOV
444
            });
×
UNCOV
445
        }
×
446
    }
447

UNCOV
448
    private setCascaderOptions() {
×
UNCOV
449
        const options = {
×
450
            labelProperty: this.thyLabelProperty(),
×
451
            valueProperty: this.thyValueProperty(),
×
452
            isMultiple: this.thyMultiple(),
453
            isOnlySelectLeaf: this.thyIsOnlySelectLeaf(),
×
454
            isLabelRenderTemplate: this.isLabelRenderTemplate(),
UNCOV
455
            loadData: this.thyLoadData()
×
UNCOV
456
        };
×
457

UNCOV
458
        this.thyCascaderService.setCascaderOptions(options);
×
459
    }
×
460

×
461
    private initPosition() {
×
462
        const cascaderPosition: ConnectionPositionPair[] = EXPANDED_DROPDOWN_POSITIONS.map(item => {
463
            return { ...item };
×
464
        });
×
465
        this.positions = cascaderPosition;
×
466
    }
×
467

468
    writeValue(value: any): void {
469
        this.thyCascaderService.writeCascaderValue(value);
UNCOV
470
        if (this.thyMultiple()) {
×
471
            this.cdr.detectChanges();
472
        }
473
    }
UNCOV
474

×
UNCOV
475
    setDisabledState(isDisabled: boolean): void {
×
UNCOV
476
        this.disabled = isDisabled;
×
UNCOV
477
    }
×
UNCOV
478

×
479
    public positionChange(position: ConnectedOverlayPositionChange): void {
UNCOV
480
        const newValue = position.connectionPair.originY === 'bottom' ? 'bottom' : 'top';
×
481
        if (this.dropDownPosition !== newValue) {
UNCOV
482
            this.dropDownPosition = newValue;
×
UNCOV
483
            this.cdr.detectChanges();
×
UNCOV
484
        }
×
UNCOV
485
    }
×
486

UNCOV
487
    public isActivatedOption(option: ThyCascaderOption, index: number): boolean {
×
488
        return this.thyCascaderService.isActivatedOption(option, index);
489
    }
490

491
    public isHalfSelectedOption(option: ThyCascaderOption, index: number): boolean {
492
        return this.thyCascaderService.isHalfSelectedOption(option, index);
UNCOV
493
    }
×
UNCOV
494

×
UNCOV
495
    public isSelectedOption(option: ThyCascaderOption, index: number): boolean {
×
496
        return this.thyCascaderService.isSelectedOption(option, index);
497
    }
498

UNCOV
499
    public attached(): void {
×
UNCOV
500
        this.cdr.detectChanges();
×
UNCOV
501
        this.cdkConnectedOverlay()
×
502
            .positionChange.pipe(take(1), takeUntil(this.destroy$))
503
            .subscribe(() => {
1✔
504
                this.scrollActiveElementIntoView();
1✔
505
            });
506
    }
507

508
    private scrollActiveElementIntoView() {
509
        if (!isEmpty(this.thyCascaderService.selectedOptions)) {
510
            const activeOptions = this.cascaderOptions()
511
                .filter(item => item.nativeElement.classList.contains('thy-cascader-menu-item-active'))
512
                // for multiple mode
513
                .slice(-this.cascaderOptionContainers().length);
514

515
            this.cascaderOptionContainers().forEach((item, index) => {
516
                if (index <= activeOptions.length - 1) {
517
                    ScrollToService.scrollToElement(activeOptions[index].nativeElement, item.nativeElement);
518
                    this.cdr.detectChanges();
519
                }
520
            });
521
        }
522
    }
523

524
    public setMenuVisible(menuVisible: boolean): void {
525
        if (this.menuVisible !== menuVisible) {
526
            this.menuVisible = menuVisible;
527

528
            this.thyCascaderService.initActivatedOptions(this.menuVisible);
529
            this.setClassMap();
530
            this.setMenuClass();
531
            if (this.menuVisible) {
532
                this.triggerRect = this.trigger().nativeElement.getBoundingClientRect();
533
                this.subscribeTriggerResize();
534
            } else {
535
                this.unsubscribeTriggerResize();
536
            }
537
            this.thyExpandStatusChange.emit(menuVisible);
538
        }
539
    }
540

541
    private setMenuClass(): void {
542
        this.menuCls = {
543
            [`${this.prefixCls}-menus`]: true,
544
            [`${this.prefixCls}-menus-hidden`]: !this.menuVisible,
545
            [`${this.thyMenuClassName()}`]: this.thyMenuClassName(),
546
            [`w-100`]: this.columns.length === 0
547
        };
548
    }
549

1✔
550
    private setLabelClass(): void {
551
        this.labelCls = {
552
            [`${this.prefixCls}-picker-label`]: true,
553
            [`${this.prefixCls}-show-search`]: false,
554
            [`${this.prefixCls}-focused`]: false,
555
            'text-truncate': true
UNCOV
556
        };
×
557
    }
558

559
    private setClassMap(): void {
560
        const classMap = {
561
            [`${this.prefixCls}`]: true,
562
            [`${this.prefixCls}-picker`]: true,
563
            [`${this.prefixCls}-${this.thySize()}`]: true,
564
            [`${this.prefixCls}-picker-disabled`]: this.disabled,
565
            [`${this.prefixCls}-picker-open`]: this.menuVisible
566
        };
567
        this.hostRenderer.updateClassByMap(classMap);
568
    }
569

570
    private isClickTriggerAction(): boolean {
571
        const thyTriggerAction = this.thyTriggerAction();
572
        if (typeof thyTriggerAction === 'string') {
573
            return thyTriggerAction === 'click';
574
        }
575
        return thyTriggerAction.indexOf('click') !== -1;
576
    }
577

578
    private isHoverTriggerAction(): boolean {
579
        const thyTriggerAction = this.thyTriggerAction();
580
        if (typeof thyTriggerAction === 'string') {
581
            return thyTriggerAction === 'hover';
582
        }
583
        return thyTriggerAction.indexOf('hover') !== -1;
584
    }
585

586
    private isHoverExpandTriggerAction(): boolean {
587
        const thyExpandTriggerAction = this.thyExpandTriggerAction();
588
        if (typeof thyExpandTriggerAction === 'string') {
589
            return thyExpandTriggerAction === 'hover';
590
        }
591
        return thyExpandTriggerAction.indexOf('hover') !== -1;
592
    }
593

594
    @HostListener('click', ['$event'])
595
    public toggleClick($event: Event) {
596
        if (this.disabled) {
597
            return;
598
        }
599
        if (this.isClickTriggerAction()) {
600
            this.setMenuVisible(!this.menuVisible);
601
        }
602
    }
603

604
    @HostListener('mouseenter', ['$event'])
605
    public toggleMouseEnter(event: MouseEvent): void {
606
        if (this.disabled || !this.isHoverTriggerAction() || this.menuVisible) {
607
            return;
608
        }
609

610
        this.setMenuVisible(true);
611
    }
612

613
    @HostListener('mouseleave', ['$event'])
614
    public toggleMouseLeave(event: MouseEvent): void {
615
        if (this.disabled || !this.isHoverTriggerAction() || !this.menuVisible) {
616
            event.preventDefault();
617
            return;
618
        }
619

620
        const hostEl = this.elementRef.nativeElement;
621
        const mouseTarget = event.relatedTarget as HTMLElement;
622
        if (
623
            hostEl.contains(mouseTarget) ||
624
            mouseTarget?.classList.contains('cdk-overlay-pane') ||
625
            mouseTarget?.classList.contains('cdk-overlay-backdrop')
626
        ) {
627
            return;
628
        }
629

630
        this.setMenuVisible(false);
631
    }
632

633
    public clickCustomOption(option: ThyCascaderOption, index: number, event: Event | boolean): void {
634
        if (event === true) {
635
            this.thyCascaderService.clearSelection();
636
        }
637
        this.thyCascaderService.clickOption(option, index, event, this.selectOption);
638
    }
639

640
    public clickOption(option: ThyCascaderOption, index: number, event: Event | boolean): void {
641
        this.thyCascaderService.removeCustomOption();
642
        this.thyCascaderService.clickOption(option, index, event, this.selectOption);
643
        if (this.cdkConnectedOverlay() && this.cdkConnectedOverlay().overlayRef) {
644
            // Make sure to calculate and update the position after the submenu is opened
645
            this.cdr.detectChanges();
646

647
            // Update the position to prevent the submenu from appearing off-screen
648
            this.cdkConnectedOverlay().overlayRef.updatePosition();
649
            this.cdr.markForCheck();
650
        }
651
    }
652

653
    public mouseoverOption(option: ThyCascaderOption, index: number, event: Event): void {
654
        if (event) {
655
            event.preventDefault();
656
        }
657

658
        if (option && option.disabled && !this.thyMultiple()) {
659
            return;
660
        }
661

662
        if (!this.isHoverExpandTriggerAction() && !(option && option.disabled && this.thyMultiple())) {
663
            return;
664
        }
665
        this.setActiveOption(option, index, false);
666
    }
667

668
    onBlur(event?: FocusEvent) {
669
        // Tab 聚焦后自动聚焦到 input 输入框,此分支下直接返回,无需触发 onTouchedFn
670
        if (elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-cascader-menus', 'thy-cascader'])) {
671
            return;
672
        }
673
        this.onTouchedFn();
674
    }
675

676
    onFocus(event?: FocusEvent) {
677
        if (!elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-cascader-menus', 'thy-cascader'])) {
678
            const inputElement: HTMLInputElement = this.elementRef.nativeElement.querySelector('input');
679
            inputElement.focus();
680
        }
681
    }
682

683
    public closeMenu(): void {
684
        if (this.menuVisible) {
685
            this.setMenuVisible(false);
686
            this.onTouchedFn();
687
            this.isShowSearchPanel = false;
688
            this.thyCascaderService.searchResultList = [];
689
        }
690
    }
691

692
    public setActiveOption(option: ThyCascaderOption, index: number, select: boolean, loadChildren: boolean = true): void {
693
        this.thyCascaderService.setActiveOption(option, index, select, loadChildren, this.selectOption);
694
    }
695

696
    private selectOption = (option: ThyCascaderOption, index: number) => {
697
        const isOnlySelectLeaf = this.thyIsOnlySelectLeaf();
698

699
        if ((option.isLeaf || !isOnlySelectLeaf) && !this.thyMultiple()) {
700
            this.afterChangeFn = () => {
701
                this.setMenuVisible(false);
702
                this.onTouchedFn();
703
            };
704
        }
705
        this.thySelect.emit({ option, index });
706
        const isOptionCanSelect = this.thyChangeOnSelect() && !this.thyMultiple();
707
        if (option.isLeaf || !isOnlySelectLeaf || isOptionCanSelect || this.shouldPerformSelection(option, index)) {
708
            this.thyCascaderService.selectOption(option, index);
709
        }
710
    };
711

712
    public removeSelectedItem(event: { item: SelectOptionBase; $eventOrigin: Event }) {
713
        event.$eventOrigin.stopPropagation();
714
        this.thyCascaderService.removeSelectedItem(event?.item);
715
    }
716

717
    private shouldPerformSelection(option: ThyCascaderOption, level: number): boolean {
718
        const thyChangeOn = this.thyChangeOn();
719
        return typeof thyChangeOn === 'function' ? thyChangeOn(option, level) === true : false;
720
    }
721

722
    public clearSelection($event: Event): void {
723
        if ($event) {
724
            $event.stopPropagation();
725
            $event.preventDefault();
726
        }
727
        this.afterChangeFn = () => {
728
            this.setMenuVisible(false);
729
        };
730
        this.thyCascaderService.clearSelection();
731
    }
732

733
    public trackByFn(index: number, item: ThyCascaderOption) {
734
        return item?.value || item?._id || index;
735
    }
736

737
    public searchFilter(searchText: string) {
738
        if (!searchText && !this.isSelectingSearchState) {
739
            this.resetSearch();
740
        }
741
        this.searchText$.next(searchText);
742
    }
743

744
    private initSearch() {
745
        this.searchText$
746
            .pipe(
747
                takeUntil(this.destroy$),
748
                DebounceTimeWrapper.debounceTime(200),
749
                distinctUntilChanged(),
750
                filter(text => text !== '')
751
            )
752
            .subscribe(searchText => {
753
                this.resetSearch();
754

755
                // local search
756
                this.searchInLocal(searchText);
757
                this.isShowSearchPanel = true;
758
                this.cdr.markForCheck();
759
            });
760
    }
761

762
    private searchInLocal(searchText: string): void {
763
        this.thyCascaderService.searchInLocal(searchText);
764
    }
765

766
    private resetSearch() {
767
        this.isShowSearchPanel = false;
768
        this.thyCascaderService.resetSearch();
769
        this.scrollActiveElementIntoView();
770
    }
771

772
    public selectSearchResult(selectOptionData: ThyCascaderSearchOption): void {
773
        const { thyRowValue: selectedOptions } = selectOptionData;
774
        if (selectOptionData.selected) {
775
            if (!this.thyMultiple()) {
776
                this.closeMenu();
777
            }
778
            return;
779
        }
780
        selectedOptions.forEach((item: ThyCascaderOption, index: number) => {
781
            this.setActiveOption(item, index, index === selectedOptions.length - 1);
782
        });
783
        if (this.thyMultiple()) {
784
            this.isSelectingSearchState = true;
785
            selectOptionData.selected = true;
786
            const originSearchResultList = this.searchResultList;
787
            // 保持搜索选项
788
            setTimeout(() => {
789
                this.isShowSearchPanel = true;
790
                this.thyCascaderService.searchResultList = originSearchResultList;
791
                this.isSelectingSearchState = false;
792
            });
793
        } else {
794
            this.resetSearch();
795
        }
796
    }
797

798
    private subscribeTriggerResize(): void {
799
        this.unsubscribeTriggerResize();
800
        this.ngZone.runOutsideAngular(() => {
801
            this.resizeSubscription = new Observable(observer => {
802
                const resize = new ResizeObserver((entries: ResizeObserverEntry[]) => {
803
                    observer.next(null);
804
                });
805
                resize.observe(this.trigger().nativeElement);
806
            }).subscribe(() => {
807
                this.ngZone.run(() => {
808
                    this.triggerRect = this.trigger().nativeElement.getBoundingClientRect();
809
                    if (this.cdkConnectedOverlay() && this.cdkConnectedOverlay().overlayRef) {
810
                        this.cdkConnectedOverlay().overlayRef.updatePosition();
811
                    }
812
                    this.cdr.markForCheck();
813
                });
814
            });
815
        });
816
    }
817

818
    private unsubscribeTriggerResize(): void {
819
        if (this.resizeSubscription) {
820
            this.resizeSubscription.unsubscribe();
821
            this.resizeSubscription = null;
822
        }
823
    }
824

825
    ngOnDestroy() {
826
        this.unsubscribeTriggerResize();
827
        this.destroy$.next();
828
        this.destroy$.complete();
829
    }
830
}
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