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

atinc / ngx-tethys / 5ba5b9d7-3ca9-4ff2-bbba-bde58c0f849f

22 Feb 2024 09:41AM UTC coverage: 90.604%. Remained the same
5ba5b9d7-3ca9-4ff2-bbba-bde58c0f849f

Pull #3027

circleci

minlovehua
feat(schematics): provide schematics for removing the suffix of standalone components #INFR-11662
Pull Request #3027: refactor: remove the component suffix for standalone components and provide schematics #INFR-10654

5425 of 6642 branches covered (81.68%)

Branch coverage included in aggregate %.

323 of 333 new or added lines in 193 files covered. (97.0%)

36 existing lines in 8 files now uncovered.

13504 of 14250 relevant lines covered (94.76%)

981.28 hits per line

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

88.71
/src/cascader/cascader.component.ts
1
import {
2
    EXPANDED_DROPDOWN_POSITIONS,
3
    InputBoolean,
4
    InputNumber,
5
    ScrollToService,
6
    TabIndexDisabledControlValueAccessorMixin,
7
    ThyClickDispatcher
8
} from 'ngx-tethys/core';
9
import { ThyEmpty } from 'ngx-tethys/empty';
10
import { ThyIcon } from 'ngx-tethys/icon';
11
import { SelectControlSize, SelectOptionBase, ThySelectControl } from 'ngx-tethys/shared';
12
import { coerceBooleanProperty, elementMatchClosest, isEmpty } from 'ngx-tethys/util';
13
import { BehaviorSubject, Observable, Subject, Subscription, timer } from 'rxjs';
14
import { debounceTime, distinctUntilChanged, filter, take, takeUntil } from 'rxjs/operators';
15
import { CdkConnectedOverlay, CdkOverlayOrigin, ConnectedOverlayPositionChange, ConnectionPositionPair } from '@angular/cdk/overlay';
16
import { NgClass, NgFor, NgIf, NgStyle, NgTemplateOutlet, isPlatformBrowser } from '@angular/common';
17
import {
18
    AfterContentInit,
19
    ChangeDetectorRef,
20
    Component,
21
    ElementRef,
22
    EventEmitter,
23
    forwardRef,
1✔
24
    HostListener,
25
    Inject,
59✔
26
    Input,
59✔
27
    NgZone,
59✔
28
    OnChanges,
5✔
29
    OnDestroy,
30
    OnInit,
31
    Output,
32
    PLATFORM_ID,
3✔
33
    QueryList,
3✔
34
    SimpleChanges,
3✔
35
    TemplateRef,
36
    ViewChild,
37
    ViewChildren
4✔
38
} from '@angular/core';
39
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
40
import { useHostRenderer } from '@tethys/cdk/dom';
37✔
41

42
import { ThyCascaderOptionComponent } from './cascader-li.component';
43
import { ThyCascaderSearchOptionComponent } from './cascader-search-option.component';
51✔
44
import { ThyCascaderExpandTrigger, ThyCascaderOption, ThyCascaderSearchOption, ThyCascaderTriggerType } from './types';
45
import { ThyCascaderService } from './cascader.service';
46
import { scaleYMotion } from 'ngx-tethys/core';
34✔
47

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

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

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

51✔
104
    /**
9!
105
     * 控制大小(4种)
9✔
106
     * @type 'sm' | 'md' | 'lg' | ''
9✔
107
     */
1✔
108
    @Input() thySize: SelectControlSize = '';
109

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

124
    /**
125
     * 点击父级菜单选项时,可通过该函数判断是否允许值的变化
1✔
126
     */
1✔
127
    @Input() thyChangeOn: (option: ThyCascaderOption, level: number) => boolean;
1✔
128

129
    /**
130
     * 点击项时,表单是否动态展示数据项
131
     * @type boolean
132
     */
133
    @Input() @InputBoolean() thyChangeOnSelect = false;
134

51✔
135
    /**
34✔
136
     * 显示输入框
34✔
137
     * @type boolean
34✔
138
     */
139
    @Input() @InputBoolean() thyShowInput = true;
140

141
    /**
142
     * 用户自定义模板
79✔
143
     * @type TemplateRef
37✔
144
     */
145
    @Input()
146
    set thyLabelRender(value: TemplateRef<any>) {
147
        this.labelRenderTpl = value;
51✔
148
        this.isLabelRenderTemplate = value instanceof TemplateRef;
204✔
149
        this.thyCascaderService.setCascaderOptions({ isLabelRenderTemplate: this.isLabelRenderTemplate });
150
    }
51✔
151

152
    get thyLabelRender(): TemplateRef<any> {
153
        return this.labelRenderTpl;
112✔
154
    }
112✔
155

29✔
156
    /**
157
     * 用于动态加载选项
158
     */
159
    @Input() set thyLoadData(value: (node: ThyCascaderOption, index?: number) => PromiseLike<any>) {
51✔
160
        this.thyCascaderService.setCascaderOptions({ loadData: value });
161
    }
162

25✔
163
    get thyLoadData() {
25✔
164
        return this.thyCascaderService?.cascaderOptions?.loadData;
1✔
165
    }
1✔
166

167
    /**
168
     * 控制触发状态, 支持 `click` | `hover`
169
     * @type ThyCascaderTriggerType | ThyCascaderTriggerType[]
1,611✔
170
     */
171
    @Input() thyTriggerAction: ThyCascaderTriggerType | ThyCascaderTriggerType[] = ['click'];
172

1,611✔
173
    /**
174
     * 鼠标经过下方列表项时,是否自动展开列表,支持 `click` | `hover`
175
     * @type ThyCascaderExpandTrigger | ThyCascaderExpandTrigger[]
1,611✔
176
     */
177
    @Input() thyExpandTriggerAction: ThyCascaderExpandTrigger | ThyCascaderExpandTrigger[] = ['click'];
178

35✔
179
    /**
35✔
180
     * 自定义浮层样式
13✔
181
     */
182
    @Input() thyMenuStyle: { [key: string]: string };
183

184
    /**
21✔
185
     * 自定义浮层类名
10✔
186
     * @type string
67✔
187
     */
188
    @Input()
189
    set thyMenuClassName(value: string) {
10✔
190
        this.menuClassName = value;
25✔
191
        this.setMenuClass();
22✔
192
    }
22✔
193

194
    get thyMenuClassName(): string {
195
        return this.menuClassName;
196
    }
197

198
    /**
76✔
199
     * 自定义浮层列类名
53✔
200
     * @type string
53✔
201
     */
53✔
202
    @Input()
53✔
203
    set thyColumnClassName(value: string) {
53✔
204
        this.columnClassName = value;
46✔
205
        this.setMenuClass();
46✔
206
    }
207

208
    get thyColumnClassName(): string {
7✔
209
        return this.columnClassName;
210
    }
53✔
211

212
    /**
213
     * 是否只读
214
     * @default false
328✔
215
     */
216
    @Input()
217
    // eslint-disable-next-line prettier/prettier
172✔
218
    override get thyDisabled(): boolean {
219
        return this.disabled;
220
    }
221

222
    override set thyDisabled(value: boolean) {
223
        this.disabled = coerceBooleanProperty(value);
224
    }
225

606✔
226
    disabled = false;
227

228
    /**
51✔
229
     * 空状态下的展示文字
230
     * @default 暂无可选项
231
     */
232
    @Input()
233
    set thyEmptyStateText(value: string) {
234
        this.emptyStateText = value;
401✔
235
    }
236

237
    /**
51✔
238
     * 是否多选
239
     * @type boolean
240
     * @default false
241
     */
242
    @Input()
243
    @InputBoolean()
244
    set thyMultiple(value: boolean) {
245
        this.isMultiple = value;
104✔
246
        this.thyCascaderService.setCascaderOptions({ isMultiple: value });
247
    }
248

249
    get thyMultiple(): boolean {
250
        return this.isMultiple;
251
    }
252

104✔
253
    /**
254
     * 设置多选时最大显示的标签数量,0 表示不限制
255
     * @type number
33✔
256
     */
24✔
257
    @Input() @InputNumber() thyMaxTagCount = 0;
258

9✔
259
    /**
260
     * 是否仅允许选择叶子项
261
     * @default true
4!
262
     */
4✔
263
    @Input()
264
    @InputBoolean()
×
265
    thyIsOnlySelectLeaf = true;
266

267
    /**
2!
268
     * 初始化时,是否展开面板
2✔
269
     * @default false
270
     */
×
271
    @Input() @InputBoolean() thyAutoExpand: boolean;
272

273
    /**
35✔
274
     * 是否支持搜索
2✔
275
     * @default false
276
     */
33!
277
    @Input() @InputBoolean() thyShowSearch: boolean = false;
33✔
278

279
    /**
280
     * 多选选中项的展示方式,默认为空,渲染文字模板,传入tag,渲染展示模板,
281
     * @default ''|tag
2!
282
     */
×
283
    @Input() thyPreset: string = '';
284

2✔
285
    /**
286
     * 是否有幕布
287
     */
2✔
288
    @Input() @InputBoolean() thyHasBackdrop = true;
1✔
289

1✔
290
    /**
291
     * 值发生变化时触发,返回选择项的值
1✔
292
     * @type EventEmitter<any[]>
1✔
293
     */
1!
294
    @Output() thyChange = new EventEmitter<any[]>();
295

296
    /**
×
297
     * 值发生变化时触发,返回选择项列表
298
     * @type EventEmitter<ThyCascaderOption[]>
1✔
299
     */
300
    @Output() thySelectionChange = new EventEmitter<ThyCascaderOption[]>();
301

20✔
302
    /**
303
     * 选择选项时触发
304
     */
2!
305
    @Output() thySelect = new EventEmitter<{
2✔
306
        option: ThyCascaderOption;
307
        index: number;
2!
308
    }>();
×
309

310
    /**
2!
311
     * @private 暂无实现
1✔
312
     */
313
    @Output() thyDeselect = new EventEmitter<{
1✔
314
        option: ThyCascaderOption;
315
        index: number;
316
    }>();
317

1!
318
    /**
×
319
     * 清空选项时触发
320
     */
1✔
321
    @Output() thyClear = new EventEmitter<void>();
322

323
    /**
1!
324
     * 下拉选项展开和折叠状态事件
1✔
325
     */
1✔
326
    @Output() thyExpandStatusChange: EventEmitter<boolean> = new EventEmitter<boolean>();
327

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

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

1✔
332
    @ViewChild(CdkConnectedOverlay, { static: true }) cdkConnectedOverlay: CdkConnectedOverlay;
1✔
333

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

336
    @ViewChild('input') input: ElementRef;
4✔
337

4✔
338
    @ViewChild('menu') menu: ElementRef;
339

340
    public dropDownPosition = 'bottom';
1✔
341

1✔
342
    public menuVisible = false;
343

344
    public isLabelRenderTemplate = false;
×
345

346
    public triggerRect: DOMRect;
347

1!
348
    public emptyStateText = '暂无可选项';
1✔
349

1✔
350
    private prefixCls = 'thy-cascader';
351

1✔
352
    private menuClassName: string;
1✔
353

354
    private columnClassName: string;
1✔
355

356
    private _menuColumnCls: any;
357

51✔
358
    private readonly destroy$ = new Subject<void>();
51✔
359

51✔
360
    private _menuCls: { [name: string]: any };
51✔
361

51✔
362
    private _labelCls: { [name: string]: any };
51✔
363

51✔
364
    private labelRenderTpl: TemplateRef<any>;
51✔
365

51✔
366
    private hostRenderer = useHostRenderer();
51✔
367

51✔
368
    public positions: ConnectionPositionPair[];
51✔
369

51✔
370
    get selected(): SelectOptionBase | SelectOptionBase[] {
51✔
371
        return this.thyMultiple ? this.thyCascaderService.selectionModel.selected : this.thyCascaderService.selectionModel.selected[0];
51✔
372
    }
51✔
373
    private isMultiple = false;
51✔
374

51✔
375
    public menuMinWidth = 122;
51✔
376

51✔
377
    private searchText$ = new BehaviorSubject('');
51✔
378

51✔
379
    public get searchResultList(): ThyCascaderSearchOption[] {
51✔
380
        return this.thyCascaderService.searchResultList;
51✔
381
    }
51✔
382

51✔
383
    public isShowSearchPanel: boolean = false;
51✔
384

51✔
385
    /**
51✔
386
     * 解决搜索&多选的情况下,点击搜索项会导致 panel 闪烁
51✔
387
     * 由于点击后,thySelectedOptions变化,导致 thySelectControl
51✔
388
     * 又会触发 searchFilter 函数,即 resetSearch 会执行
51✔
389
     * 会导致恢复级联状态再变为搜索状态
51✔
390
     */
51✔
391
    private isSelectingSearchState: boolean = false;
51✔
392

51✔
393
    public get isLoading() {
51✔
394
        return this.thyCascaderService?.isLoading;
51✔
395
    }
396

397
    public get columns() {
398
        return this.thyCascaderService.columns;
399
    }
400

401
    private afterChangeFn: () => void;
51✔
402

51✔
403
    private resizeSubscription: Subscription;
9✔
404

4✔
405
    ngOnInit(): void {
4✔
406
        this.setClassMap();
4✔
407
        this.setMenuClass();
408
        this.setMenuColumnClass();
409
        this.setLabelClass();
9✔
410
        this.initPosition();
9✔
411
        this.initSearch();
9!
412
        const options = {
9✔
413
            labelProperty: this.thyLabelProperty,
414
            valueProperty: this.thyValueProperty,
415
            isMultiple: this.isMultiple,
416
            isOnlySelectLeaf: this.thyIsOnlySelectLeaf,
417
            isLabelRenderTemplate: this.isLabelRenderTemplate,
1,045!
418
            loadData: this.thyLoadData
419
        };
420
        this.thyCascaderService.setCascaderOptions(options);
7✔
421

1✔
422
        this.thyCascaderService.cascaderValueChange().subscribe(options => {
423
            if (!options.isValueEqual) {
7✔
424
                this.onChangeFn(options.value);
425
                if (options.isSelectionModelEmpty) {
426
                    this.thyClear.emit();
51✔
427
                }
52✔
428
                this.thySelectionChange.emit(this.thyCascaderService.selectedOptions);
429
                this.thyChange.emit(options.value);
6✔
430
                if (this.afterChangeFn) {
431
                    this.afterChangeFn();
6✔
432
                    this.afterChangeFn = null;
6✔
433
                }
434
            }
435
        });
436

6✔
437
        if (isPlatformBrowser(this.platformId)) {
438
            this.thyClickDispatcher
439
                .clicked(0)
8✔
440
                .pipe(takeUntil(this.destroy$))
8✔
441
                .subscribe(event => {
8✔
442
                    if (
443
                        !this.elementRef.nativeElement.contains(event.target) &&
444
                        !this.menu?.nativeElement.contains(event.target as Node) &&
1✔
445
                        this.menuVisible
1!
446
                    ) {
×
447
                        this.ngZone.run(() => {
×
448
                            this.closeMenu();
449
                            this.cdr.markForCheck();
×
450
                        });
451
                    }
1✔
452
                });
3✔
453
        }
454
    }
1!
455

×
456
    ngAfterContentInit() {
×
457
        if (this.thyAutoExpand) {
×
458
            timer(0).subscribe(() => {
459
                this.cdr.markForCheck();
×
460
                this.setMenuVisible(true);
×
461
            });
×
462
        }
×
463
    }
464

465
    ngOnChanges(changes: SimpleChanges): void {
466
        if (changes['thyIsOnlySelectLeaf']) {
1✔
467
            this.thyCascaderService.setCascaderOptions({ isOnlySelectLeaf: changes['thyIsOnlySelectLeaf'].currentValue });
468
        }
469
    }
470

46✔
471
    private initPosition() {
46✔
472
        const cascaderPosition: ConnectionPositionPair[] = EXPANDED_DROPDOWN_POSITIONS.map(item => {
46✔
473
            return { ...item };
46✔
474
        });
55✔
475
        this.positions = cascaderPosition;
476
    }
46✔
477

478
    writeValue(value: any): void {
19✔
479
        this.thyCascaderService.writeCascaderValue(value);
19✔
480
        if (this.isMultiple) {
19✔
481
            this.cdr.detectChanges();
12✔
482
        }
483
    }
19✔
484

485
    setDisabledState(isDisabled: boolean): void {
486
        this.disabled = isDisabled;
487
    }
488

489
    public positionChange(position: ConnectedOverlayPositionChange): void {
104✔
490
        const newValue = position.connectionPair.originY === 'bottom' ? 'bottom' : 'top';
36✔
491
        if (this.dropDownPosition !== newValue) {
36✔
492
            this.dropDownPosition = newValue;
493
            this.cdr.detectChanges();
494
        }
495
    }
51✔
496

51✔
497
    public isActivatedOption(option: ThyCascaderOption, index: number): boolean {
51✔
498
        return this.thyCascaderService.isActivatedOption(option, index);
499
    }
1✔
500

501
    public isHalfSelectedOption(option: ThyCascaderOption, index: number): boolean {
502
        return this.thyCascaderService.isHalfSelectedOption(option, index);
503
    }
504

505
    public isSelectedOption(option: ThyCascaderOption, index: number): boolean {
506
        return this.thyCascaderService.isSelectedOption(option, index);
507
    }
1✔
508

509
    public attached(): void {
510
        this.cdr.detectChanges();
511
        this.cdkConnectedOverlay.positionChange.pipe(take(1), takeUntil(this.destroy$)).subscribe(() => {
512
            this.scrollActiveElementIntoView();
513
        });
514
    }
515

516
    private scrollActiveElementIntoView() {
517
        if (!isEmpty(this.thyCascaderService.selectedOptions)) {
518
            const activeOptions = this.cascaderOptions
519
                .filter(item => item.nativeElement.classList.contains('thy-cascader-menu-item-active'))
520
                // for multiple mode
521
                .slice(-this.cascaderOptionContainers.length);
522

523
            this.cascaderOptionContainers.forEach((item, index) => {
524
                if (index <= activeOptions.length - 1) {
525
                    ScrollToService.scrollToElement(activeOptions[index].nativeElement, item.nativeElement);
526
                    this.cdr.detectChanges();
527
                }
528
            });
529
        }
530
    }
531

532
    public setMenuVisible(menuVisible: boolean): void {
533
        if (this.menuVisible !== menuVisible) {
534
            this.menuVisible = menuVisible;
535

536
            this.thyCascaderService.initActivatedOptions(this.menuVisible);
537
            this.setClassMap();
538
            this.setMenuClass();
539
            if (this.menuVisible) {
540
                this.triggerRect = this.trigger.nativeElement.getBoundingClientRect();
541
                this.subscribeTriggerResize();
542
            } else {
543
                this.unsubscribeTriggerResize();
544
            }
545
            this.thyExpandStatusChange.emit(menuVisible);
546
        }
547
    }
548

549
    public get menuCls(): any {
1✔
550
        return this._menuCls;
551
    }
552

553
    private setMenuClass(): void {
1✔
554
        this._menuCls = {
555
            [`${this.prefixCls}-menus`]: true,
556
            [`${this.prefixCls}-menus-hidden`]: !this.menuVisible,
557
            [`${this.thyMenuClassName}`]: this.thyMenuClassName,
1✔
558
            [`w-100`]: this.columns.length === 0
559
        };
560
    }
561

562
    public get menuColumnCls(): any {
1✔
563
        return this._menuColumnCls;
564
    }
565

566
    private setMenuColumnClass(): void {
1✔
567
        this._menuColumnCls = {
568
            [`${this.prefixCls}-menu`]: true,
569
            [`${this.thyColumnClassName}`]: this.thyColumnClassName
570
        };
1✔
571
    }
572

573
    public get labelCls(): any {
574
        return this._labelCls;
1✔
575
    }
576

577
    private setLabelClass(): void {
578
        this._labelCls = {
1✔
579
            [`${this.prefixCls}-picker-label`]: true,
580
            [`${this.prefixCls}-show-search`]: false,
581
            [`${this.prefixCls}-focused`]: false,
582
            'text-truncate': true
1✔
583
        };
584
    }
585

586
    private setClassMap(): void {
587
        const classMap = {
588
            [`${this.prefixCls}`]: true,
589
            [`${this.prefixCls}-picker`]: true,
51✔
590
            [`${this.prefixCls}-${this.thySize}`]: true,
591
            [`${this.prefixCls}-picker-disabled`]: this.disabled,
592
            [`${this.prefixCls}-picker-open`]: this.menuVisible
593
        };
594
        this.hostRenderer.updateClassByMap(classMap);
595
    }
596

597
    private isClickTriggerAction(): boolean {
598
        if (typeof this.thyTriggerAction === 'string') {
599
            return this.thyTriggerAction === 'click';
600
        }
601
        return this.thyTriggerAction.indexOf('click') !== -1;
602
    }
603

604
    private isHoverTriggerAction(): boolean {
605
        if (typeof this.thyTriggerAction === 'string') {
606
            return this.thyTriggerAction === 'hover';
607
        }
608
        return this.thyTriggerAction.indexOf('hover') !== -1;
609
    }
610

611
    private isHoverExpandTriggerAction(): boolean {
612
        if (typeof this.thyExpandTriggerAction === 'string') {
613
            return this.thyExpandTriggerAction === 'hover';
614
        }
615
        return this.thyExpandTriggerAction.indexOf('hover') !== -1;
616
    }
617

618
    @HostListener('click', ['$event'])
619
    public toggleClick($event: Event) {
620
        if (this.disabled) {
621
            return;
622
        }
623
        if (this.isClickTriggerAction()) {
624
            this.setMenuVisible(!this.menuVisible);
625
        }
626
    }
627

628
    @HostListener('mouseenter', ['$event'])
629
    public toggleMouseEnter(event: MouseEvent): void {
630
        if (this.disabled || !this.isHoverTriggerAction() || this.menuVisible) {
631
            return;
632
        }
633

634
        this.setMenuVisible(true);
635
    }
636

637
    @HostListener('mouseleave', ['$event'])
638
    public toggleMouseLeave(event: MouseEvent): void {
639
        if (this.disabled || !this.isHoverTriggerAction() || !this.menuVisible) {
640
            event.preventDefault();
641
            return;
642
        }
643

644
        const hostEl = this.elementRef.nativeElement;
645
        const mouseTarget = event.relatedTarget as HTMLElement;
646
        if (
647
            hostEl.contains(mouseTarget) ||
648
            mouseTarget?.classList.contains('cdk-overlay-pane') ||
649
            mouseTarget?.classList.contains('cdk-overlay-backdrop')
650
        ) {
651
            return;
652
        }
653

654
        this.setMenuVisible(false);
655
    }
656

657
    public clickOption(option: ThyCascaderOption, index: number, event: Event | boolean): void {
658
        this.thyCascaderService.clickOption(option, index, event, this.selectOption);
659
    }
660

661
    public mouseoverOption(option: ThyCascaderOption, index: number, event: Event): void {
662
        if (event) {
663
            event.preventDefault();
664
        }
665

666
        if (option && option.disabled && !this.isMultiple) {
667
            return;
668
        }
669

670
        if (!this.isHoverExpandTriggerAction() && !(option && option.disabled && this.isMultiple)) {
671
            return;
672
        }
673
        this.setActiveOption(option, index, false);
674
    }
675

676
    onBlur(event?: FocusEvent) {
677
        // Tab 聚焦后自动聚焦到 input 输入框,此分支下直接返回,无需触发 onTouchedFn
678
        if (elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-cascader-menus', 'thy-cascader'])) {
679
            return;
680
        }
681
        this.onTouchedFn();
682
    }
683

684
    onFocus(event?: FocusEvent) {
685
        if (!elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-cascader-menus', 'thy-cascader'])) {
686
            const inputElement: HTMLInputElement = this.elementRef.nativeElement.querySelector('input');
687
            inputElement.focus();
688
        }
689
    }
690

691
    public closeMenu(): void {
692
        if (this.menuVisible) {
693
            this.setMenuVisible(false);
694
            this.onTouchedFn();
695
            this.isShowSearchPanel = false;
696
            this.thyCascaderService.searchResultList = [];
697
        }
698
    }
699

700
    public setActiveOption(option: ThyCascaderOption, index: number, select: boolean, loadChildren: boolean = true): void {
701
        this.thyCascaderService.setActiveOption(option, index, select, loadChildren, this.selectOption);
702
    }
703

704
    private selectOption = (option: ThyCascaderOption, index: number) => {
705
        if ((option.isLeaf || !this.thyIsOnlySelectLeaf) && !this.thyMultiple) {
706
            this.afterChangeFn = () => {
707
                this.setMenuVisible(false);
708
                this.onTouchedFn();
709
            };
710
        }
711
        this.thySelect.emit({ option, index });
712
        const isOptionCanSelect = this.thyChangeOnSelect && !this.isMultiple;
713
        if (option.isLeaf || !this.thyIsOnlySelectLeaf || isOptionCanSelect || this.shouldPerformSelection(option, index)) {
714
            this.thyCascaderService.selectOption(option, index);
715
        }
716
    };
717

718
    public removeSelectedItem(event: { item: SelectOptionBase; $eventOrigin: Event }) {
719
        event.$eventOrigin.stopPropagation();
720
        this.thyCascaderService.removeSelectedItem(event?.item);
721
    }
722

723
    private shouldPerformSelection(option: ThyCascaderOption, level: number): boolean {
724
        return typeof this.thyChangeOn === 'function' ? this.thyChangeOn(option, level) === true : false;
725
    }
726

727
    public clearSelection($event: Event): void {
728
        if ($event) {
729
            $event.stopPropagation();
730
            $event.preventDefault();
731
        }
732
        this.afterChangeFn = () => {
733
            this.setMenuVisible(false);
734
        };
735
        this.thyCascaderService.clearSelection();
736
    }
737

738
    constructor(
739
        @Inject(PLATFORM_ID) private platformId: string,
740
        private cdr: ChangeDetectorRef,
741
        public elementRef: ElementRef,
742
        private thyClickDispatcher: ThyClickDispatcher,
743
        private ngZone: NgZone,
744
        public thyCascaderService: ThyCascaderService
745
    ) {
746
        super();
747
    }
748

749
    public trackByFn(index: number, item: ThyCascaderOption) {
750
        return item?.value || item?._id || index;
751
    }
752

753
    public searchFilter(searchText: string) {
754
        if (!searchText && !this.isSelectingSearchState) {
755
            this.resetSearch();
756
        }
757
        this.searchText$.next(searchText);
758
    }
759

760
    private initSearch() {
761
        this.searchText$
762
            .pipe(
763
                takeUntil(this.destroy$),
764
                debounceTime(200),
765
                distinctUntilChanged(),
766
                filter(text => text !== '')
767
            )
768
            .subscribe(searchText => {
769
                this.resetSearch();
770

771
                // local search
772
                this.searchInLocal(searchText);
773
                this.isShowSearchPanel = true;
774
            });
775
    }
776

777
    private searchInLocal(searchText: string): void {
778
        this.thyCascaderService.searchInLocal(searchText);
779
    }
780

781
    private resetSearch() {
782
        this.isShowSearchPanel = false;
783
        this.thyCascaderService.resetSearch();
784
        this.scrollActiveElementIntoView();
785
    }
786

787
    public selectSearchResult(selectOptionData: ThyCascaderSearchOption): void {
788
        const { thyRowValue: selectedOptions } = selectOptionData;
789
        if (selectOptionData.selected) {
790
            if (!this.isMultiple) {
791
                this.closeMenu();
792
            }
793
            return;
794
        }
795
        selectedOptions.forEach((item: ThyCascaderOption, index: number) => {
796
            this.setActiveOption(item, index, index === selectedOptions.length - 1);
797
        });
798
        if (this.isMultiple) {
799
            this.isSelectingSearchState = true;
800
            selectOptionData.selected = true;
801
            const originSearchResultList = this.searchResultList;
802
            // 保持搜索选项
803
            setTimeout(() => {
804
                this.isShowSearchPanel = true;
805
                this.thyCascaderService.searchResultList = originSearchResultList;
806
                this.isSelectingSearchState = false;
807
            });
808
        } else {
809
            this.resetSearch();
810
        }
811
    }
812

813
    private subscribeTriggerResize(): void {
814
        this.unsubscribeTriggerResize();
815
        this.ngZone.runOutsideAngular(() => {
816
            this.resizeSubscription = new Observable(observer => {
817
                const resize = new ResizeObserver((entries: ResizeObserverEntry[]) => {
818
                    observer.next();
819
                });
820
                resize.observe(this.trigger.nativeElement);
821
            }).subscribe(() => {
822
                this.ngZone.run(() => {
823
                    this.triggerRect = this.trigger.nativeElement.getBoundingClientRect();
824
                    if (this.cdkConnectedOverlay && this.cdkConnectedOverlay.overlayRef) {
825
                        this.cdkConnectedOverlay.overlayRef.updatePosition();
826
                    }
827
                    this.cdr.markForCheck();
828
                });
829
            });
830
        });
831
    }
832

833
    private unsubscribeTriggerResize(): void {
834
        if (this.resizeSubscription) {
835
            this.resizeSubscription.unsubscribe();
836
            this.resizeSubscription = null;
837
        }
838
    }
839

840
    ngOnDestroy() {
841
        this.unsubscribeTriggerResize();
842
        this.destroy$.next();
843
        this.destroy$.complete();
844
    }
845
}
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