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

atinc / ngx-tethys / cf65e7d1-9df0-45a5-a655-00fd832d8cdd

10 Apr 2024 07:39AM UTC coverage: 90.386% (+0.02%) from 90.364%
cf65e7d1-9df0-45a5-a655-00fd832d8cdd

Pull #3071

circleci

FrankWang117
fix(cascader): remove console
Pull Request #3071: feat(cascader): support custom options #INFR-12126

5435 of 6659 branches covered (81.62%)

Branch coverage included in aggregate %.

10 of 10 new or added lines in 2 files covered. (100.0%)

35 existing lines in 6 files now uncovered.

13170 of 13925 relevant lines covered (94.58%)

983.5 hits per line

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

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

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

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

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

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

107
    /**
108
     * 控制大小(4种)
109
     * @type 'sm' | 'md' | 'lg' | ''
59✔
110
     */
59✔
111
    @Input() thySize: SelectControlSize = '';
15!
112

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

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

136
    get thyCustomOptions() {
137
        return this.thyCascaderService.customOptions;
138
    }
139

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

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

57✔
151
    /**
152
     * 显示输入框
153
     * @type boolean
154
     */
59✔
155
    @Input({ transform: booleanAttribute }) thyShowInput = true;
236✔
156

157
    /**
59✔
158
     * 用户自定义选项模板
159
     * @type TemplateRef
160
     */
129✔
161
    @Input() thyOptionRender: TemplateRef<SafeAny>;
129✔
162

37✔
163
    /**
164
     * 用户自定义模板
165
     * @type TemplateRef
166
     */
59✔
167
    @Input()
168
    set thyLabelRender(value: TemplateRef<any>) {
169
        this.labelRenderTpl = value;
17!
170
        this.isLabelRenderTemplate = value instanceof TemplateRef;
17!
UNCOV
171
        this.thyCascaderService.setCascaderOptions({ isLabelRenderTemplate: this.isLabelRenderTemplate });
×
UNCOV
172
    }
×
173

174
    get thyLabelRender(): TemplateRef<any> {
175
        return this.labelRenderTpl;
176
    }
2,418✔
177

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

185
    get thyLoadData() {
42✔
186
        return this.thyCascaderService?.cascaderOptions?.loadData;
42✔
187
    }
17✔
188

189
    /**
190
     * 控制触发状态, 支持 `click` | `hover`
191
     * @type ThyCascaderTriggerType | ThyCascaderTriggerType[]
26✔
192
     */
12✔
193
    @Input() thyTriggerAction: ThyCascaderTriggerType | ThyCascaderTriggerType[] = ['click'];
83✔
194

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

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

88✔
206
    /**
62✔
207
     * 自定义搜索样式
62✔
208
     */
62✔
209
    @Input() thySearchListStyle: { [key: string]: string };
62✔
210

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

221
    get thyMenuClassName(): string {
451✔
222
        return this.menuClassName;
223
    }
224

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

235
    get thyColumnClassName(): string {
59✔
236
        return this.columnClassName;
237
    }
238

239
    /**
240
     * 是否只读
241
     * @default false
546✔
242
     */
243
    @Input({ transform: booleanAttribute })
244
    override set thyDisabled(value: boolean) {
59✔
245
        this.disabled = value;
246
    }
247
    override get thyDisabled(): boolean {
248
        return this.disabled;
249
    }
250

251
    disabled = false;
252

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

262
    /**
40✔
263
     * 是否多选
27✔
264
     * @type boolean
265
     * @default false
13✔
266
     */
267
    @Input({ transform: booleanAttribute })
268
    set thyMultiple(value: boolean) {
4!
269
        this.isMultiple = value;
4✔
270
        this.thyCascaderService.setCascaderOptions({ isMultiple: value });
UNCOV
271
    }
×
272

273
    get thyMultiple(): boolean {
274
        return this.isMultiple;
2!
275
    }
2✔
276

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

283
    /**
40!
284
     * 是否仅允许选择叶子项
40✔
285
     * @default true
286
     */
287
    @Input({ transform: booleanAttribute })
288
    thyIsOnlySelectLeaf = true;
2!
UNCOV
289

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

1✔
296
    /**
1✔
297
     * 是否支持搜索
298
     * @default false
1✔
299
     */
1✔
300
    @Input({ transform: booleanAttribute }) thyShowSearch: boolean = false;
1!
301

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

308
    /**
4✔
309
     * 是否有幕布
3✔
310
     */
311
    @Input({ transform: booleanAttribute }) thyHasBackdrop = true;
4✔
312

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

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

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

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

341
    /**
342
     * 清空选项时触发
343
     */
4✔
344
    @Output() thyClear = new EventEmitter<void>();
1✔
345

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

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

353
    @ViewChildren('cascaderOptionContainers', { read: ElementRef }) cascaderOptionContainers: QueryList<ElementRef>;
354

1✔
355
    @ViewChild(CdkConnectedOverlay, { static: true }) cdkConnectedOverlay: CdkConnectedOverlay;
1✔
356

357
    @ViewChild('trigger', { read: ElementRef, static: true }) trigger: ElementRef<any>;
UNCOV
358

×
359
    @ViewChild('input') input: ElementRef;
360

361
    @ViewChild('menu') menu: ElementRef;
1!
362

1✔
363
    public dropDownPosition = 'bottom';
1✔
364

365
    public menuVisible = false;
1✔
366

1✔
367
    public isLabelRenderTemplate = false;
368

1✔
369
    public triggerRect: DOMRect;
370

371
    public emptyStateText = '暂无可选项';
59✔
372

59✔
373
    private prefixCls = 'thy-cascader';
59✔
374

59✔
375
    private menuClassName: string;
59✔
376

59✔
377
    private columnClassName: string;
59✔
378

59✔
379
    private _menuColumnCls: any;
59✔
380

59✔
381
    private readonly destroy$ = new Subject<void>();
59✔
382

59✔
383
    private _menuCls: { [name: string]: any };
59✔
384

59✔
385
    private _labelCls: { [name: string]: any };
59✔
386

59✔
387
    private labelRenderTpl: TemplateRef<any>;
59✔
388

59✔
389
    private hostRenderer = useHostRenderer();
59✔
390

59✔
391
    public positions: ConnectionPositionPair[];
59✔
392

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

59✔
398
    public menuMinWidth = 122;
59✔
399

59✔
400
    private searchText$ = new BehaviorSubject('');
59✔
401

59✔
402
    public get searchResultList(): ThyCascaderSearchOption[] {
59✔
403
        return this.thyCascaderService.searchResultList;
59✔
404
    }
59✔
405

59✔
406
    public isShowSearchPanel: boolean = false;
59✔
407

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

59✔
416
    public get isLoading() {
59✔
417
        return this.thyCascaderService?.isLoading;
15✔
418
    }
6✔
419

6✔
420
    public get columns() {
6✔
421
        return this.thyCascaderService.columns;
422
    }
423

15✔
424
    private afterChangeFn: () => void;
15✔
425

15!
426
    private resizeSubscription: Subscription;
15✔
427

428
    ngOnInit(): void {
429
        this.setClassMap();
430
        this.setMenuClass();
431
        this.setMenuColumnClass();
1,490!
432
        this.setLabelClass();
433
        this.initPosition();
434
        this.initSearch();
8✔
435
        const options = {
1✔
436
            labelProperty: this.thyLabelProperty,
437
            valueProperty: this.thyValueProperty,
8✔
438
            isMultiple: this.isMultiple,
439
            isOnlySelectLeaf: this.thyIsOnlySelectLeaf,
440
            isLabelRenderTemplate: this.isLabelRenderTemplate,
59✔
441
            loadData: this.thyLoadData
60✔
442
        };
443
        this.thyCascaderService.setCascaderOptions(options);
7✔
444

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

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

479
    ngAfterContentInit() {
480
        if (this.thyAutoExpand) {
481
            timer(0).subscribe(() => {
1✔
482
                this.cdr.markForCheck();
483
                this.setMenuVisible(true);
484
            });
485
        }
53✔
486
    }
53✔
487

53✔
488
    ngOnChanges(changes: SimpleChanges): void {
53✔
489
        if (changes['thyIsOnlySelectLeaf']) {
66✔
490
            this.thyCascaderService.setCascaderOptions({ isOnlySelectLeaf: changes['thyIsOnlySelectLeaf'].currentValue });
491
        }
53✔
492
    }
493

23✔
494
    private initPosition() {
23✔
495
        const cascaderPosition: ConnectionPositionPair[] = EXPANDED_DROPDOWN_POSITIONS.map(item => {
23✔
496
            return { ...item };
16✔
497
        });
498
        this.positions = cascaderPosition;
23✔
499
    }
500

501
    writeValue(value: any): void {
502
        this.thyCascaderService.writeCascaderValue(value);
503
        if (this.isMultiple) {
504
            this.cdr.detectChanges();
121✔
505
        }
43✔
506
    }
43✔
507

508
    setDisabledState(isDisabled: boolean): void {
509
        this.disabled = isDisabled;
510
    }
59✔
511

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

520
    public isActivatedOption(option: ThyCascaderOption, index: number): boolean {
521
        return this.thyCascaderService.isActivatedOption(option, index);
522
    }
1✔
523

524
    public isHalfSelectedOption(option: ThyCascaderOption, index: number): boolean {
525
        return this.thyCascaderService.isHalfSelectedOption(option, index);
526
    }
527

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

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

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

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

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

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

572
    public get menuCls(): any {
573
        return this._menuCls;
574
    }
59✔
575

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

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

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

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

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

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

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

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

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

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

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

657
        this.setMenuVisible(true);
658
    }
659

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

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

677
        this.setMenuVisible(false);
678
    }
679

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

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

692
    public mouseoverOption(option: ThyCascaderOption, index: number, event: Event): void {
693
        if (event) {
694
            event.preventDefault();
695
        }
696

697
        if (option && option.disabled && !this.isMultiple) {
698
            return;
699
        }
700

701
        if (!this.isHoverExpandTriggerAction() && !(option && option.disabled && this.isMultiple)) {
702
            return;
703
        }
704
        this.setActiveOption(option, index, false);
705
    }
706

707
    onBlur(event?: FocusEvent) {
708
        // Tab 聚焦后自动聚焦到 input 输入框,此分支下直接返回,无需触发 onTouchedFn
709
        if (elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-cascader-menus', 'thy-cascader'])) {
710
            return;
711
        }
712
        this.onTouchedFn();
713
    }
714

715
    onFocus(event?: FocusEvent) {
716
        if (!elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-cascader-menus', 'thy-cascader'])) {
717
            const inputElement: HTMLInputElement = this.elementRef.nativeElement.querySelector('input');
718
            inputElement.focus();
719
        }
720
    }
721

722
    public closeMenu(): void {
723
        if (this.menuVisible) {
724
            this.setMenuVisible(false);
725
            this.onTouchedFn();
726
            this.isShowSearchPanel = false;
727
            this.thyCascaderService.searchResultList = [];
728
        }
729
    }
730

731
    public setActiveOption(option: ThyCascaderOption, index: number, select: boolean, loadChildren: boolean = true): void {
732
        this.thyCascaderService.setActiveOption(option, index, select, loadChildren, this.selectOption);
733
    }
734

735
    private selectOption = (option: ThyCascaderOption, index: number) => {
736
        if ((option.isLeaf || !this.thyIsOnlySelectLeaf) && !this.thyMultiple) {
737
            this.afterChangeFn = () => {
738
                this.setMenuVisible(false);
739
                this.onTouchedFn();
740
            };
741
        }
742
        this.thySelect.emit({ option, index });
743
        const isOptionCanSelect = this.thyChangeOnSelect && !this.isMultiple;
744
        if (option.isLeaf || !this.thyIsOnlySelectLeaf || isOptionCanSelect || this.shouldPerformSelection(option, index)) {
745
            this.thyCascaderService.selectOption(option, index);
746
        }
747
    };
748

749
    public removeSelectedItem(event: { item: SelectOptionBase; $eventOrigin: Event }) {
750
        event.$eventOrigin.stopPropagation();
751
        this.thyCascaderService.removeSelectedItem(event?.item);
752
    }
753

754
    private shouldPerformSelection(option: ThyCascaderOption, level: number): boolean {
755
        return typeof this.thyChangeOn === 'function' ? this.thyChangeOn(option, level) === true : false;
756
    }
757

758
    public clearSelection($event: Event): void {
759
        if ($event) {
760
            $event.stopPropagation();
761
            $event.preventDefault();
762
        }
763
        this.afterChangeFn = () => {
764
            this.setMenuVisible(false);
765
        };
766
        this.thyCascaderService.clearSelection();
767
    }
768

769
    constructor(
770
        @Inject(PLATFORM_ID) private platformId: string,
771
        private cdr: ChangeDetectorRef,
772
        public elementRef: ElementRef,
773
        private thyClickDispatcher: ThyClickDispatcher,
774
        private ngZone: NgZone,
775
        public thyCascaderService: ThyCascaderService
776
    ) {
777
        super();
778
    }
779

780
    public trackByFn(index: number, item: ThyCascaderOption) {
781
        return item?.value || item?._id || index;
782
    }
783

784
    public searchFilter(searchText: string) {
785
        if (!searchText && !this.isSelectingSearchState) {
786
            this.resetSearch();
787
        }
788
        this.searchText$.next(searchText);
789
    }
790

791
    private initSearch() {
792
        this.searchText$
793
            .pipe(
794
                takeUntil(this.destroy$),
795
                debounceTime(200),
796
                distinctUntilChanged(),
797
                filter(text => text !== '')
798
            )
799
            .subscribe(searchText => {
800
                this.resetSearch();
801

802
                // local search
803
                this.searchInLocal(searchText);
804
                this.isShowSearchPanel = true;
805
                this.cdr.markForCheck();
806
            });
807
    }
808

809
    private searchInLocal(searchText: string): void {
810
        this.thyCascaderService.searchInLocal(searchText);
811
    }
812

813
    private resetSearch() {
814
        this.isShowSearchPanel = false;
815
        this.thyCascaderService.resetSearch();
816
        this.scrollActiveElementIntoView();
817
    }
818

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

845
    private subscribeTriggerResize(): void {
846
        this.unsubscribeTriggerResize();
847
        this.ngZone.runOutsideAngular(() => {
848
            this.resizeSubscription = new Observable(observer => {
849
                const resize = new ResizeObserver((entries: ResizeObserverEntry[]) => {
850
                    observer.next();
851
                });
852
                resize.observe(this.trigger.nativeElement);
853
            }).subscribe(() => {
854
                this.ngZone.run(() => {
855
                    this.triggerRect = this.trigger.nativeElement.getBoundingClientRect();
856
                    if (this.cdkConnectedOverlay && this.cdkConnectedOverlay.overlayRef) {
857
                        this.cdkConnectedOverlay.overlayRef.updatePosition();
858
                    }
859
                    this.cdr.markForCheck();
860
                });
861
            });
862
        });
863
    }
864

865
    private unsubscribeTriggerResize(): void {
866
        if (this.resizeSubscription) {
867
            this.resizeSubscription.unsubscribe();
868
            this.resizeSubscription = null;
869
        }
870
    }
871

872
    ngOnDestroy() {
873
        this.unsubscribeTriggerResize();
874
        this.destroy$.next();
875
        this.destroy$.complete();
876
    }
877
}
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