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

atinc / ngx-tethys / 5fa9630c-19a1-4ee7-af3d-6a0c3535952a

08 Oct 2024 08:24AM UTC coverage: 90.438% (+0.007%) from 90.431%
5fa9630c-19a1-4ee7-af3d-6a0c3535952a

push

circleci

minlovehua
refactor: refactor all control-flow directives to new control-flow #TINFR-381

5511 of 6738 branches covered (81.79%)

Branch coverage included in aggregate %.

98 of 104 new or added lines in 58 files covered. (94.23%)

113 existing lines in 17 files now uncovered.

13253 of 14010 relevant lines covered (94.6%)

991.73 hits per line

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

89.03
/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
    EventEmitter,
9
    forwardRef,
10
    HostListener,
11
    Inject,
12
    Input,
13
    NgZone,
14
    numberAttribute,
15
    OnChanges,
16
    OnDestroy,
17
    OnInit,
18
    Output,
19
    PLATFORM_ID,
20
    QueryList,
21
    SimpleChanges,
22
    TemplateRef,
23
    ViewChild,
24
    ViewChildren
1✔
25
} from '@angular/core';
26
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
71✔
27
import { useHostRenderer } from '@tethys/cdk/dom';
71✔
28
import {
71✔
29
    EXPANDED_DROPDOWN_POSITIONS,
5✔
30
    DebounceTimeWrapper,
31
    ScrollToService,
32
    TabIndexDisabledControlValueAccessorMixin,
33
    ThyClickDispatcher
62✔
34
} from 'ngx-tethys/core';
35
import { ThyEmpty } from 'ngx-tethys/empty';
36
import { ThyIcon } from 'ngx-tethys/icon';
1,990✔
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';
5✔
40
import { BehaviorSubject, Observable, Subject, Subscription, timer } from 'rxjs';
5✔
41
import { debounceTime, distinctUntilChanged, filter, take, takeUntil } from 'rxjs/operators';
5✔
42

43
import { scaleYMotion } from 'ngx-tethys/core';
44
import { ThyDivider } from 'ngx-tethys/divider';
4✔
45
import { ThyCascaderOptionComponent } from './cascader-li.component';
46
import { ThyCascaderSearchOptionComponent } from './cascader-search-option.component';
47
import { ThyCascaderService } from './cascader.service';
40✔
48
import { ThyCascaderExpandTrigger, ThyCascaderOption, ThyCascaderSearchOption, ThyCascaderTriggerType } from './types';
49

50
/**
63✔
51
 * 级联选择菜单
52
 * @name thy-cascader
53
 */
37✔
54
@Component({
37✔
55
    selector: 'thy-cascader,[thy-cascader]',
56
    templateUrl: './cascader.component.html',
57
    providers: [
404✔
58
        {
59
            provide: NG_VALUE_ACCESSOR,
60
            useExisting: forwardRef(() => ThyCascader),
37✔
61
            multi: true
37✔
62
        },
63
        ThyCascaderService
64
    ],
126✔
65
    host: {
66
        '[attr.tabindex]': `tabIndex`,
67
        '(focus)': 'onFocus($event)',
58✔
68
        '(blur)': 'onBlur($event)'
69
    },
70
    standalone: true,
655✔
71
    imports: [
72
        CdkOverlayOrigin,
73
        ThySelectControl,
38✔
74
        NgClass,
75
        NgTemplateOutlet,
76
        CdkConnectedOverlay,
59✔
77
        NgStyle,
59✔
78
        ThyCascaderOptionComponent,
79
        ThyCascaderSearchOptionComponent,
80
        ThyEmpty,
4,477✔
81
        ThyIcon,
82
        ThyDivider
83
    ],
814✔
84
    animations: [scaleYMotion]
85
})
86
export class ThyCascader
32✔
87
    extends TabIndexDisabledControlValueAccessorMixin
88
    implements ControlValueAccessor, OnInit, OnChanges, OnDestroy, AfterContentInit
UNCOV
89
{
×
90
    /**
91
     * 选项的实际值的属性名
92
     */
1,182✔
93
    @Input() thyValueProperty = 'value';
94

95
    /**
63✔
96
     * 选项的显示值的属性名
63✔
97
     */
63✔
98
    @Input() thyLabelProperty = 'label';
63✔
99

63✔
100
    /**
63✔
101
     * 描述输入字段预期值的简短的提示信息
63✔
102
     */
103
    @Input() thyPlaceholder = '请选择';
104

105
    /**
106
     * 控制大小(5种)
107
     * @type 'xs' | 'sm' | 'md' | 'lg' | ''
108
     */
109
    @Input() thySize: SelectControlSize = '';
63✔
110

63✔
111
    /**
16!
112
     * 数据项
16✔
113
     * @type ThyCascaderOption[]
16✔
114
     * @default []
1✔
115
     */
116
    @Input()
16✔
117
    set thyOptions(options: ThyCascaderOption[] | null) {
16✔
118
        const columns = options && options.length ? [options] : [];
16✔
119
        this.thyCascaderService.initColumns(columns);
7✔
120
        if (this.thyCascaderService.defaultValue && columns.length) {
7✔
121
            this.thyCascaderService.initOptions(0);
122
        }
123
    }
124

63!
125
    /**
63✔
126
     * 自定义选项
127
     * @type ThyCascaderOption[]
128
     * @default []
129
     */
55✔
130
    @Input() set thyCustomOptions(options: ThyCascaderOption[] | null) {
131
        this.thyCascaderService.customOptions = (options || []).map(item => ({ ...item }));
132
    }
1✔
133

1✔
134
    get thyCustomOptions() {
1✔
135
        return this.thyCascaderService.customOptions;
136
    }
137

138
    /**
139
     * 点击父级菜单选项时,可通过该函数判断是否允许值的变化
140
     */
141
    @Input() thyChangeOn: (option: ThyCascaderOption, level: number) => boolean;
63✔
142

37✔
143
    /**
37✔
144
     * 点击项时,表单是否动态展示数据项
37✔
145
     * @type boolean
146
     */
147
    @Input({ transform: coerceBooleanProperty }) thyChangeOnSelect = false;
148

149
    /**
98✔
150
     * 显示输入框
61✔
151
     * @type boolean
152
     */
153
    @Input({ transform: coerceBooleanProperty }) thyShowInput = true;
154

63✔
155
    /**
252✔
156
     * 用户自定义选项模板
157
     * @type TemplateRef
63✔
158
     */
159
    @Input() thyOptionRender: TemplateRef<SafeAny>;
160

137✔
161
    /**
137✔
162
     * 用户自定义模板
45✔
163
     * @type TemplateRef
164
     */
165
    @Input()
166
    set thyLabelRender(value: TemplateRef<any>) {
63✔
167
        this.labelRenderTpl = value;
168
        this.isLabelRenderTemplate = value instanceof TemplateRef;
169
        this.thyCascaderService.setCascaderOptions({ isLabelRenderTemplate: this.isLabelRenderTemplate });
25✔
170
    }
25✔
171

14✔
172
    get thyLabelRender(): TemplateRef<any> {
14✔
173
        return this.labelRenderTpl;
174
    }
175

176
    /**
2,819✔
177
     * 用于动态加载选项
178
     */
179
    @Input() set thyLoadData(value: (node: ThyCascaderOption, index?: number) => PromiseLike<any>) {
2,819✔
180
        this.thyCascaderService.setCascaderOptions({ loadData: value });
181
    }
182

2,819✔
183
    get thyLoadData() {
184
        return this.thyCascaderService?.cascaderOptions?.loadData;
185
    }
45✔
186

45✔
187
    /**
21✔
188
     * 控制触发状态, 支持 `click` | `hover`
189
     * @type ThyCascaderTriggerType | ThyCascaderTriggerType[]
190
     */
191
    @Input() thyTriggerAction: ThyCascaderTriggerType | ThyCascaderTriggerType[] = ['click'];
30✔
192

19✔
193
    /**
112✔
194
     * 鼠标经过下方列表项时,是否自动展开列表,支持 `click` | `hover`
195
     * @type ThyCascaderExpandTrigger | ThyCascaderExpandTrigger[]
196
     */
19✔
197
    @Input() thyExpandTriggerAction: ThyCascaderExpandTrigger | ThyCascaderExpandTrigger[] = ['click'];
43✔
198

38✔
199
    /**
38✔
200
     * 自定义浮层样式
201
     */
202
    @Input() thyMenuStyle: { [key: string]: string };
203

204
    /**
205
     * 自定义搜索样式
91✔
206
     */
65✔
207
    @Input() thySearchListStyle: { [key: string]: string };
65✔
208

65✔
209
    /**
65✔
210
     * 自定义浮层类名
65✔
211
     * @type string
56✔
212
     */
56✔
213
    @Input()
214
    set thyMenuClassName(value: string) {
215
        this.menuClassName = value;
9✔
216
        this.setMenuClass();
217
    }
65✔
218

219
    get thyMenuClassName(): string {
220
        return this.menuClassName;
221
    }
514✔
222

223
    /**
224
     * 自定义浮层列类名
202✔
225
     * @type string
226
     */
227
    @Input()
228
    set thyColumnClassName(value: string) {
229
        this.columnClassName = value;
230
        this.setMenuClass();
231
    }
232

958✔
233
    get thyColumnClassName(): string {
234
        return this.columnClassName;
235
    }
63✔
236

237
    /**
238
     * 是否只读
239
     * @default false
240
     */
241
    @Input({ transform: coerceBooleanProperty })
666✔
242
    override set thyDisabled(value: boolean) {
243
        this.disabled = value;
244
    }
63✔
245
    override get thyDisabled(): boolean {
246
        return this.disabled;
247
    }
248

249
    disabled = false;
250

251
    /**
252
     * 空状态下的展示文字
128✔
253
     * @default 暂无可选项
254
     */
255
    @Input()
256
    set thyEmptyStateText(value: string) {
257
        this.emptyStateText = value;
258
    }
259

128✔
260
    /**
261
     * 是否多选
262
     * @type boolean
43✔
263
     * @default false
27✔
264
     */
265
    @Input({ transform: coerceBooleanProperty })
16✔
266
    set thyMultiple(value: boolean) {
267
        this.isMultiple = value;
268
        this.thyCascaderService.setCascaderOptions({ isMultiple: value });
4!
269
    }
4✔
270

UNCOV
271
    get thyMultiple(): boolean {
×
272
        return this.isMultiple;
273
    }
274

2!
275
    /**
2✔
276
     * 设置多选时最大显示的标签数量,0 表示不限制
UNCOV
277
     * @type number
×
278
     */
279
    @Input({ transform: numberAttribute }) thyMaxTagCount = 0;
280

45✔
281
    /**
2✔
282
     * 是否仅允许选择叶子项
283
     * @default true
43!
284
     */
43✔
285
    @Input({ transform: coerceBooleanProperty })
286
    thyIsOnlySelectLeaf = true;
287

288
    /**
2!
UNCOV
289
     * 初始化时,是否展开面板
×
290
     * @default false
291
     */
2✔
292
    @Input({ transform: coerceBooleanProperty }) thyAutoExpand: boolean;
293

294
    /**
2✔
295
     * 是否支持搜索
1✔
296
     * @default false
1✔
297
     */
298
    @Input({ transform: coerceBooleanProperty }) thyShowSearch: boolean = false;
1✔
299

1✔
300
    /**
1!
301
     * 多选选中项的展示方式,默认为空,渲染文字模板,传入tag,渲染展示模板,
302
     * @default ''|tag
UNCOV
303
     */
×
304
    @Input() thyPreset: string = '';
305

1✔
306
    /**
307
     * 是否有幕布
308
     */
5✔
309
    @Input({ transform: coerceBooleanProperty }) thyHasBackdrop = true;
4✔
310

311
    /**
5✔
312
     * 值发生变化时触发,返回选择项的值
313
     * @type EventEmitter<any[]>
314
     */
24✔
315
    @Output() thyChange = new EventEmitter<any[]>();
24✔
316

24!
317
    /**
318
     * 值发生变化时触发,返回选择项列表
24✔
319
     * @type EventEmitter<ThyCascaderOption[]>
320
     */
24✔
321
    @Output() thySelectionChange = new EventEmitter<ThyCascaderOption[]>();
24✔
322

323
    /**
324
     * 选择选项时触发
325
     */
2!
326
    @Output() thySelect = new EventEmitter<{
2✔
327
        option: ThyCascaderOption;
328
        index: number;
2!
UNCOV
329
    }>();
×
330

331
    /**
2!
332
     * @private 暂无实现
1✔
333
     */
334
    @Output() thyDeselect = new EventEmitter<{
1✔
335
        option: ThyCascaderOption;
336
        index: number;
337
    }>();
338

1!
UNCOV
339
    /**
×
340
     * 清空选项时触发
341
     */
1✔
342
    @Output() thyClear = new EventEmitter<void>();
343

344
    /**
1!
345
     * 下拉选项展开和折叠状态事件
1✔
346
     */
1✔
347
    @Output() thyExpandStatusChange: EventEmitter<boolean> = new EventEmitter<boolean>();
348

349
    @ViewChildren('cascaderOptions', { read: ElementRef }) cascaderOptions: QueryList<ElementRef>;
350

4✔
351
    @ViewChildren('cascaderOptionContainers', { read: ElementRef }) cascaderOptionContainers: QueryList<ElementRef>;
1✔
352

1✔
353
    @ViewChild(CdkConnectedOverlay, { static: true }) cdkConnectedOverlay: CdkConnectedOverlay;
1✔
354

1✔
355
    @ViewChild('trigger', { read: ElementRef, static: true }) trigger: ElementRef<any>;
356

357
    @ViewChild('input') input: ElementRef;
4✔
358

4✔
359
    @ViewChild('menu') menu: ElementRef;
360

361
    public dropDownPosition = 'bottom';
1✔
362

1✔
363
    public menuVisible = false;
364

UNCOV
365
    public isLabelRenderTemplate = false;
×
366

367
    public triggerRect: DOMRect;
368

1!
369
    public emptyStateText = '暂无可选项';
1✔
370

1✔
371
    private prefixCls = 'thy-cascader';
372

1✔
373
    private menuClassName: string;
1✔
374

375
    private columnClassName: string;
1✔
376

377
    private _menuColumnCls: any;
378

63✔
379
    private readonly destroy$ = new Subject<void>();
63✔
380

63✔
381
    private _menuCls: { [name: string]: any };
63✔
382

63✔
383
    private _labelCls: { [name: string]: any };
63✔
384

63✔
385
    private labelRenderTpl: TemplateRef<any>;
63✔
386

63✔
387
    private hostRenderer = useHostRenderer();
63✔
388

63✔
389
    public positions: ConnectionPositionPair[];
63✔
390

63✔
391
    get selected(): SelectOptionBase | SelectOptionBase[] {
63✔
392
        return this.thyMultiple ? this.thyCascaderService.selectionModel.selected : this.thyCascaderService.selectionModel.selected[0];
63✔
393
    }
63✔
394
    private isMultiple = false;
63✔
395

63✔
396
    public menuMinWidth = 122;
63✔
397

63✔
398
    private searchText$ = new BehaviorSubject('');
63✔
399

63✔
400
    public get searchResultList(): ThyCascaderSearchOption[] {
63✔
401
        return this.thyCascaderService.searchResultList;
63✔
402
    }
63✔
403

63✔
404
    public isShowSearchPanel: boolean = false;
63✔
405

63✔
406
    /**
63✔
407
     * 解决搜索&多选的情况下,点击搜索项会导致 panel 闪烁
63✔
408
     * 由于点击后,thySelectedOptions变化,导致 thySelectControl
63✔
409
     * 又会触发 searchFilter 函数,即 resetSearch 会执行
63✔
410
     * 会导致恢复级联状态再变为搜索状态
63✔
411
     */
63✔
412
    private isSelectingSearchState: boolean = false;
63✔
413

63✔
414
    public get isLoading() {
63✔
415
        return this.thyCascaderService?.isLoading;
63✔
416
    }
417

418
    public get columns() {
419
        return this.thyCascaderService.columns;
420
    }
421

422
    private afterChangeFn: () => void;
63✔
423

63✔
424
    private resizeSubscription: Subscription;
16✔
425

6✔
426
    ngOnInit(): void {
6✔
427
        this.setClassMap();
6✔
428
        this.setMenuClass();
429
        this.setMenuColumnClass();
430
        this.setLabelClass();
16✔
431
        this.initPosition();
16✔
432
        this.initSearch();
16!
433
        const options = {
16✔
434
            labelProperty: this.thyLabelProperty,
435
            valueProperty: this.thyValueProperty,
436
            isMultiple: this.isMultiple,
437
            isOnlySelectLeaf: this.thyIsOnlySelectLeaf,
438
            isLabelRenderTemplate: this.isLabelRenderTemplate,
2,832!
439
            loadData: this.thyLoadData
440
        };
441
        this.thyCascaderService.setCascaderOptions(options);
8✔
442

1✔
443
        this.thyCascaderService.cascaderValueChange().subscribe(options => {
444
            if (!options.isValueEqual) {
8✔
445
                this.onChangeFn(options.value);
446
                if (options.isSelectionModelEmpty) {
447
                    this.thyClear.emit();
63✔
448
                }
64✔
449
                this.thySelectionChange.emit(this.thyCascaderService.selectedOptions);
450
                this.thyChange.emit(options.value);
7✔
451
                if (this.afterChangeFn) {
452
                    this.afterChangeFn();
7✔
453
                    this.afterChangeFn = null;
7✔
454
                }
7✔
455
            }
456
        });
457

458
        if (isPlatformBrowser(this.platformId)) {
7✔
459
            this.thyClickDispatcher
460
                .clicked(0)
461
                .pipe(takeUntil(this.destroy$))
9✔
462
                .subscribe(event => {
9✔
463
                    if (
9✔
464
                        !this.elementRef.nativeElement.contains(event.target) &&
465
                        !this.menu?.nativeElement.contains(event.target as Node) &&
466
                        this.menuVisible
1✔
467
                    ) {
1!
UNCOV
468
                        this.ngZone.run(() => {
×
469
                            this.closeMenu();
×
470
                            this.cdr.markForCheck();
UNCOV
471
                        });
×
472
                    }
473
                });
1✔
474
        }
3✔
475
    }
476

1!
477
    ngAfterContentInit() {
×
UNCOV
478
        if (this.thyAutoExpand) {
×
479
            timer(0).subscribe(() => {
×
480
                this.cdr.markForCheck();
481
                this.setMenuVisible(true);
×
482
            });
×
UNCOV
483
        }
×
UNCOV
484
    }
×
485

486
    ngOnChanges(changes: SimpleChanges): void {
487
        if (changes['thyIsOnlySelectLeaf']) {
488
            this.thyCascaderService.setCascaderOptions({ isOnlySelectLeaf: changes['thyIsOnlySelectLeaf'].currentValue });
1✔
489
        }
490
    }
491

492
    private initPosition() {
56✔
493
        const cascaderPosition: ConnectionPositionPair[] = EXPANDED_DROPDOWN_POSITIONS.map(item => {
56✔
494
            return { ...item };
56✔
495
        });
56✔
496
        this.positions = cascaderPosition;
70✔
497
    }
498

56✔
499
    writeValue(value: any): void {
500
        this.thyCascaderService.writeCascaderValue(value);
24✔
501
        if (this.isMultiple) {
24✔
502
            this.cdr.detectChanges();
24✔
503
        }
17✔
504
    }
505

24✔
506
    setDisabledState(isDisabled: boolean): void {
507
        this.disabled = isDisabled;
508
    }
509

510
    public positionChange(position: ConnectedOverlayPositionChange): void {
511
        const newValue = position.connectionPair.originY === 'bottom' ? 'bottom' : 'top';
128✔
512
        if (this.dropDownPosition !== newValue) {
46✔
513
            this.dropDownPosition = newValue;
46✔
514
            this.cdr.detectChanges();
515
        }
516
    }
517

63✔
518
    public isActivatedOption(option: ThyCascaderOption, index: number): boolean {
63✔
519
        return this.thyCascaderService.isActivatedOption(option, index);
63✔
520
    }
521

1✔
522
    public isHalfSelectedOption(option: ThyCascaderOption, index: number): boolean {
523
        return this.thyCascaderService.isHalfSelectedOption(option, index);
524
    }
525

526
    public isSelectedOption(option: ThyCascaderOption, index: number): boolean {
527
        return this.thyCascaderService.isSelectedOption(option, index);
528
    }
529

1✔
530
    public attached(): void {
531
        this.cdr.detectChanges();
532
        this.cdkConnectedOverlay.positionChange.pipe(take(1), takeUntil(this.destroy$)).subscribe(() => {
533
            this.scrollActiveElementIntoView();
534
        });
535
    }
536

537
    private scrollActiveElementIntoView() {
538
        if (!isEmpty(this.thyCascaderService.selectedOptions)) {
539
            const activeOptions = this.cascaderOptions
540
                .filter(item => item.nativeElement.classList.contains('thy-cascader-menu-item-active'))
541
                // for multiple mode
542
                .slice(-this.cascaderOptionContainers.length);
543

544
            this.cascaderOptionContainers.forEach((item, index) => {
545
                if (index <= activeOptions.length - 1) {
546
                    ScrollToService.scrollToElement(activeOptions[index].nativeElement, item.nativeElement);
547
                    this.cdr.detectChanges();
548
                }
549
            });
550
        }
551
    }
552

553
    public setMenuVisible(menuVisible: boolean): void {
554
        if (this.menuVisible !== menuVisible) {
555
            this.menuVisible = menuVisible;
556

557
            this.thyCascaderService.initActivatedOptions(this.menuVisible);
558
            this.setClassMap();
559
            this.setMenuClass();
560
            if (this.menuVisible) {
561
                this.triggerRect = this.trigger.nativeElement.getBoundingClientRect();
562
                this.subscribeTriggerResize();
563
            } else {
564
                this.unsubscribeTriggerResize();
565
            }
566
            this.thyExpandStatusChange.emit(menuVisible);
567
        }
568
    }
569

570
    public get menuCls(): any {
571
        return this._menuCls;
572
    }
573

574
    private setMenuClass(): void {
1✔
575
        this._menuCls = {
576
            [`${this.prefixCls}-menus`]: true,
577
            [`${this.prefixCls}-menus-hidden`]: !this.menuVisible,
578
            [`${this.thyMenuClassName}`]: this.thyMenuClassName,
579
            [`w-100`]: this.columns.length === 0
580
        };
581
    }
63✔
582

583
    public get menuColumnCls(): any {
584
        return this._menuColumnCls;
585
    }
586

587
    private setMenuColumnClass(): void {
588
        this._menuColumnCls = {
589
            [`${this.prefixCls}-menu`]: true,
590
            [`${this.thyColumnClassName}`]: this.thyColumnClassName
591
        };
592
    }
593

594
    public get labelCls(): any {
595
        return this._labelCls;
596
    }
597

598
    private setLabelClass(): void {
599
        this._labelCls = {
600
            [`${this.prefixCls}-picker-label`]: true,
601
            [`${this.prefixCls}-show-search`]: false,
602
            [`${this.prefixCls}-focused`]: false,
603
            'text-truncate': true
604
        };
605
    }
606

607
    private setClassMap(): void {
608
        const classMap = {
609
            [`${this.prefixCls}`]: true,
610
            [`${this.prefixCls}-picker`]: true,
611
            [`${this.prefixCls}-${this.thySize}`]: true,
612
            [`${this.prefixCls}-picker-disabled`]: this.disabled,
613
            [`${this.prefixCls}-picker-open`]: this.menuVisible
614
        };
615
        this.hostRenderer.updateClassByMap(classMap);
616
    }
617

618
    private isClickTriggerAction(): boolean {
619
        if (typeof this.thyTriggerAction === 'string') {
620
            return this.thyTriggerAction === 'click';
621
        }
622
        return this.thyTriggerAction.indexOf('click') !== -1;
623
    }
624

625
    private isHoverTriggerAction(): boolean {
626
        if (typeof this.thyTriggerAction === 'string') {
627
            return this.thyTriggerAction === 'hover';
628
        }
629
        return this.thyTriggerAction.indexOf('hover') !== -1;
630
    }
631

632
    private isHoverExpandTriggerAction(): boolean {
633
        if (typeof this.thyExpandTriggerAction === 'string') {
634
            return this.thyExpandTriggerAction === 'hover';
635
        }
636
        return this.thyExpandTriggerAction.indexOf('hover') !== -1;
637
    }
638

639
    @HostListener('click', ['$event'])
640
    public toggleClick($event: Event) {
641
        if (this.disabled) {
642
            return;
643
        }
644
        if (this.isClickTriggerAction()) {
645
            this.setMenuVisible(!this.menuVisible);
646
        }
647
    }
648

649
    @HostListener('mouseenter', ['$event'])
650
    public toggleMouseEnter(event: MouseEvent): void {
651
        if (this.disabled || !this.isHoverTriggerAction() || this.menuVisible) {
652
            return;
653
        }
654

655
        this.setMenuVisible(true);
656
    }
657

658
    @HostListener('mouseleave', ['$event'])
659
    public toggleMouseLeave(event: MouseEvent): void {
660
        if (this.disabled || !this.isHoverTriggerAction() || !this.menuVisible) {
661
            event.preventDefault();
662
            return;
663
        }
664

665
        const hostEl = this.elementRef.nativeElement;
666
        const mouseTarget = event.relatedTarget as HTMLElement;
667
        if (
668
            hostEl.contains(mouseTarget) ||
669
            mouseTarget?.classList.contains('cdk-overlay-pane') ||
670
            mouseTarget?.classList.contains('cdk-overlay-backdrop')
671
        ) {
672
            return;
673
        }
674

675
        this.setMenuVisible(false);
676
    }
677

678
    public clickCustomOption(option: ThyCascaderOption, index: number, event: Event | boolean): void {
679
        if (event === true) {
680
            this.thyCascaderService.clearSelection();
681
        }
682
        this.thyCascaderService.clickOption(option, index, event, this.selectOption);
683
    }
684

685
    public clickOption(option: ThyCascaderOption, index: number, event: Event | boolean): void {
686
        this.thyCascaderService.removeCustomOption();
687
        this.thyCascaderService.clickOption(option, index, event, this.selectOption);
688

689
        if (this.cdkConnectedOverlay && this.cdkConnectedOverlay.overlayRef) {
690
            // Make sure to calculate and update the position after the submenu is opened
691
            this.cdr.detectChanges();
692

693
            // Update the position to prevent the submenu from appearing off-screen
694
            this.cdkConnectedOverlay.overlayRef.updatePosition();
695
            this.cdr.markForCheck();
696
        }
697
    }
698

699
    public mouseoverOption(option: ThyCascaderOption, index: number, event: Event): void {
700
        if (event) {
701
            event.preventDefault();
702
        }
703

704
        if (option && option.disabled && !this.isMultiple) {
705
            return;
706
        }
707

708
        if (!this.isHoverExpandTriggerAction() && !(option && option.disabled && this.isMultiple)) {
709
            return;
710
        }
711
        this.setActiveOption(option, index, false);
712
    }
713

714
    onBlur(event?: FocusEvent) {
715
        // Tab 聚焦后自动聚焦到 input 输入框,此分支下直接返回,无需触发 onTouchedFn
716
        if (elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-cascader-menus', 'thy-cascader'])) {
717
            return;
718
        }
719
        this.onTouchedFn();
720
    }
721

722
    onFocus(event?: FocusEvent) {
723
        if (!elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-cascader-menus', 'thy-cascader'])) {
724
            const inputElement: HTMLInputElement = this.elementRef.nativeElement.querySelector('input');
725
            inputElement.focus();
726
        }
727
    }
728

729
    public closeMenu(): void {
730
        if (this.menuVisible) {
731
            this.setMenuVisible(false);
732
            this.onTouchedFn();
733
            this.isShowSearchPanel = false;
734
            this.thyCascaderService.searchResultList = [];
735
        }
736
    }
737

738
    public setActiveOption(option: ThyCascaderOption, index: number, select: boolean, loadChildren: boolean = true): void {
739
        this.thyCascaderService.setActiveOption(option, index, select, loadChildren, this.selectOption);
740
    }
741

742
    private selectOption = (option: ThyCascaderOption, index: number) => {
743
        if ((option.isLeaf || !this.thyIsOnlySelectLeaf) && !this.thyMultiple) {
744
            this.afterChangeFn = () => {
745
                this.setMenuVisible(false);
746
                this.onTouchedFn();
747
            };
748
        }
749
        this.thySelect.emit({ option, index });
750
        const isOptionCanSelect = this.thyChangeOnSelect && !this.isMultiple;
751
        if (option.isLeaf || !this.thyIsOnlySelectLeaf || isOptionCanSelect || this.shouldPerformSelection(option, index)) {
752
            this.thyCascaderService.selectOption(option, index);
753
        }
754
    };
755

756
    public removeSelectedItem(event: { item: SelectOptionBase; $eventOrigin: Event }) {
757
        event.$eventOrigin.stopPropagation();
758
        this.thyCascaderService.removeSelectedItem(event?.item);
759
    }
760

761
    private shouldPerformSelection(option: ThyCascaderOption, level: number): boolean {
762
        return typeof this.thyChangeOn === 'function' ? this.thyChangeOn(option, level) === true : false;
763
    }
764

765
    public clearSelection($event: Event): void {
766
        if ($event) {
767
            $event.stopPropagation();
768
            $event.preventDefault();
769
        }
770
        this.afterChangeFn = () => {
771
            this.setMenuVisible(false);
772
        };
773
        this.thyCascaderService.clearSelection();
774
    }
775

776
    constructor(
777
        @Inject(PLATFORM_ID) private platformId: string,
778
        private cdr: ChangeDetectorRef,
779
        public elementRef: ElementRef,
780
        private thyClickDispatcher: ThyClickDispatcher,
781
        private ngZone: NgZone,
782
        public thyCascaderService: ThyCascaderService
783
    ) {
784
        super();
785
    }
786

787
    public trackByFn(index: number, item: ThyCascaderOption) {
788
        return item?.value || item?._id || index;
789
    }
790

791
    public searchFilter(searchText: string) {
792
        if (!searchText && !this.isSelectingSearchState) {
793
            this.resetSearch();
794
        }
795
        this.searchText$.next(searchText);
796
    }
797

798
    private initSearch() {
799
        this.searchText$
800
            .pipe(
801
                takeUntil(this.destroy$),
802
                DebounceTimeWrapper.debounceTime(200),
803
                distinctUntilChanged(),
804
                filter(text => text !== '')
805
            )
806
            .subscribe(searchText => {
807
                this.resetSearch();
808

809
                // local search
810
                this.searchInLocal(searchText);
811
                this.isShowSearchPanel = true;
812
                this.cdr.markForCheck();
813
            });
814
    }
815

816
    private searchInLocal(searchText: string): void {
817
        this.thyCascaderService.searchInLocal(searchText);
818
    }
819

820
    private resetSearch() {
821
        this.isShowSearchPanel = false;
822
        this.thyCascaderService.resetSearch();
823
        this.scrollActiveElementIntoView();
824
    }
825

826
    public selectSearchResult(selectOptionData: ThyCascaderSearchOption): void {
827
        const { thyRowValue: selectedOptions } = selectOptionData;
828
        if (selectOptionData.selected) {
829
            if (!this.isMultiple) {
830
                this.closeMenu();
831
            }
832
            return;
833
        }
834
        selectedOptions.forEach((item: ThyCascaderOption, index: number) => {
835
            this.setActiveOption(item, index, index === selectedOptions.length - 1);
836
        });
837
        if (this.isMultiple) {
838
            this.isSelectingSearchState = true;
839
            selectOptionData.selected = true;
840
            const originSearchResultList = this.searchResultList;
841
            // 保持搜索选项
842
            setTimeout(() => {
843
                this.isShowSearchPanel = true;
844
                this.thyCascaderService.searchResultList = originSearchResultList;
845
                this.isSelectingSearchState = false;
846
            });
847
        } else {
848
            this.resetSearch();
849
        }
850
    }
851

852
    private subscribeTriggerResize(): void {
853
        this.unsubscribeTriggerResize();
854
        this.ngZone.runOutsideAngular(() => {
855
            this.resizeSubscription = new Observable(observer => {
856
                const resize = new ResizeObserver((entries: ResizeObserverEntry[]) => {
857
                    observer.next();
858
                });
859
                resize.observe(this.trigger.nativeElement);
860
            }).subscribe(() => {
861
                this.ngZone.run(() => {
862
                    this.triggerRect = this.trigger.nativeElement.getBoundingClientRect();
863
                    if (this.cdkConnectedOverlay && this.cdkConnectedOverlay.overlayRef) {
864
                        this.cdkConnectedOverlay.overlayRef.updatePosition();
865
                    }
866
                    this.cdr.markForCheck();
867
                });
868
            });
869
        });
870
    }
871

872
    private unsubscribeTriggerResize(): void {
873
        if (this.resizeSubscription) {
874
            this.resizeSubscription.unsubscribe();
875
            this.resizeSubscription = null;
876
        }
877
    }
878

879
    ngOnDestroy() {
880
        this.unsubscribeTriggerResize();
881
        this.destroy$.next();
882
        this.destroy$.complete();
883
    }
884
}
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