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

atinc / ngx-tethys / 09185295-1759-4043-ba2f-a4daa80c72fd

16 Nov 2023 08:14AM UTC coverage: 90.204% (+0.03%) from 90.171%
09185295-1759-4043-ba2f-a4daa80c72fd

push

circleci

minlovehua
feat(switch): loading demo

5269 of 6504 branches covered (0.0%)

Branch coverage included in aggregate %.

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

47 existing lines in 3 files now uncovered.

13185 of 13954 relevant lines covered (94.49%)

977.48 hits per line

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

87.18
/src/cascader/cascader.component.ts
1
import {
2
    EXPANDED_DROPDOWN_POSITIONS,
3
    InputBoolean,
4
    InputNumber,
5
    ScrollToService,
6
    TabIndexDisabledControlValueAccessorMixin
7
} from 'ngx-tethys/core';
8
import { ThyEmptyComponent } from 'ngx-tethys/empty';
9
import { ThyIconComponent } from 'ngx-tethys/icon';
10
import { SelectControlSize, SelectOptionBase, ThySelectControlComponent } from 'ngx-tethys/shared';
11
import { Id } from 'ngx-tethys/types';
12
import { coerceBooleanProperty, elementMatchClosest, isArray, isEmpty, set, helpers } from 'ngx-tethys/util';
13
import { BehaviorSubject, Subject } from 'rxjs';
14
import { debounceTime, distinctUntilChanged, filter, take, takeUntil } from 'rxjs/operators';
15

16
import { SelectionModel } from '@angular/cdk/collections';
17
import {
18
    CdkConnectedOverlay,
19
    CdkOverlayOrigin,
20
    ConnectedOverlayPositionChange,
21
    ConnectionPositionPair,
22
    ViewportRuler
130✔
23
} from '@angular/cdk/overlay';
77✔
24
import { NgClass, NgFor, NgIf, NgStyle, NgTemplateOutlet } from '@angular/common';
25
import {
53✔
26
    ChangeDetectorRef,
6✔
27
    Component,
28
    ElementRef,
29
    EventEmitter,
47✔
30
    forwardRef,
31
    HostListener,
130✔
32
    Input,
33
    OnDestroy,
34
    OnInit,
96✔
35
    Output,
73✔
36
    QueryList,
37
    TemplateRef,
23✔
38
    ViewChild,
23✔
39
    ViewChildren
29✔
40
} from '@angular/core';
19✔
41
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
42
import { useHostRenderer } from '@tethys/cdk/dom';
43

4✔
44
import { ThyCascaderOptionComponent } from './cascader-li.component';
45
import { ThyCascaderSearchOptionComponent } from './cascader-search-option.component';
42✔
46
import { ThyCascaderExpandTrigger, ThyCascaderOption, ThyCascaderSearchOption, ThyCascaderTriggerType } from './types';
47
import { deepCopy } from '@angular-devkit/core';
48

49
function toArray<T>(value: T | T[]): T[] {
50
    let ret: T[];
1✔
51
    if (value == null) {
52
        ret = [];
54✔
53
    } else if (!Array.isArray(value)) {
54✔
54
        ret = [value];
4✔
55
    } else {
56
        ret = value;
57
    }
58
    return ret;
3✔
59
}
3✔
60

61
function arrayEquals<T>(array1: T[], array2: T[]): boolean {
62
    if (!array1 || !array2 || array1.length !== array2.length) {
6✔
63
        return false;
64
    }
65

30✔
66
    const len = array1.length;
30✔
67
    for (let i = 0; i < len; i++) {
68
        if (array1[i] !== array2[i]) {
69
            return false;
284✔
70
        }
71
    }
72
    return true;
30✔
73
}
30✔
74

75
const defaultDisplayRender = (label: any) => label.join(' / ');
76

94✔
77
/**
78
 * 级联选择菜单
79
 * @name thy-cascader
400✔
80
 */
81
@Component({
82
    selector: 'thy-cascader,[thy-cascader]',
44✔
83
    templateUrl: './cascader.component.html',
84
    providers: [
85
        {
31✔
86
            provide: NG_VALUE_ACCESSOR,
87
            useExisting: forwardRef(() => ThyCascaderComponent),
88
            multi: true
12✔
89
        }
12✔
90
    ],
91
    host: {
92
        '[attr.tabindex]': `tabIndex`,
2,219✔
93
        '(focus)': 'onFocus($event)',
94
        '(blur)': 'onBlur($event)'
95
    },
478✔
96
    styles: [
478✔
97
        `
98
            .thy-cascader-menus {
99
                position: relative;
47✔
100
            }
47✔
101
        `
47✔
102
    ],
47✔
103
    standalone: true,
47✔
104
    imports: [
47✔
105
        CdkOverlayOrigin,
47✔
106
        NgIf,
35✔
107
        ThySelectControlComponent,
108
        NgClass,
47✔
109
        NgTemplateOutlet,
110
        CdkConnectedOverlay,
111
        NgStyle,
112
        NgFor,
×
113
        ThyCascaderOptionComponent,
×
114
        ThyCascaderSearchOptionComponent,
×
115
        ThyEmptyComponent,
116
        ThyIconComponent
117
    ]
118
})
119
export class ThyCascaderComponent extends TabIndexDisabledControlValueAccessorMixin implements ControlValueAccessor, OnInit, OnDestroy {
12!
120
    /**
×
121
     * 选项的实际值的属性名
122
     */
123
    @Input() thyValueProperty = 'value';
12✔
124

125
    /**
126
     * 选项的显示值的属性名
127
     */
47✔
128
    @Input() thyLabelProperty = 'label';
188✔
129

130
    /**
47✔
131
     * 描述输入字段预期值的简短的提示信息
47✔
132
     */
47✔
133
    @Input() thyPlaceholder = '请选择';
47✔
134

47✔
135
    /**
136
     * 控制大小(4种)
137
     * @type 'sm' | 'md' | 'lg' | ''
107✔
138
     */
107✔
139
    @Input() thySize: SelectControlSize = '';
106✔
140

106✔
141
    /**
64✔
142
     * 数据项
143
     * @type ThyCascaderOption[]
106✔
144
     * @default []
38✔
145
     */
146
    @Input()
147
    set thyOptions(options: ThyCascaderOption[] | null) {
107✔
148
        this.columns = options && options.length ? [options] : [];
105✔
149
        if (this.defaultValue && this.columns.length) {
150
            this.initOptions(0);
151
        }
2✔
152
    }
2✔
153

154
    /**
155
     * 点击父级菜单选项时,可通过该函数判断是否允许值的变化
156
     */
106✔
157
    @Input() thyChangeOn: (option: ThyCascaderOption, level: number) => boolean;
106✔
158

10✔
159
    /**
10!
160
     * 点击项时,表单是否动态展示数据项
161
     * @type boolean
162
     */
10!
163
    @Input() @InputBoolean() thyChangeOnSelect = false;
10!
164

165
    /**
166
     * 显示输入框
106✔
167
     * @type boolean
106✔
168
     */
169
    @Input() @InputBoolean() thyShowInput = true;
170

125✔
171
    /**
106✔
172
     * 用户自定义模板
31✔
173
     * @type TemplateRef
174
     */
106✔
175
    @Input()
176
    set thyLabelRender(value: TemplateRef<any>) {
177
        this.labelRenderTpl = value;
19✔
178
        this.isLabelRenderTemplate = value instanceof TemplateRef;
4✔
179
    }
4✔
UNCOV
180

×
181
    get thyLabelRender(): TemplateRef<any> {
182
        return this.labelRenderTpl;
4✔
183
    }
184

19✔
185
    /**
10✔
186
     * 用于动态加载选项
187
     */
188
    @Input() thyLoadData: (node: ThyCascaderOption, index?: number) => PromiseLike<any>;
9✔
189

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

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

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

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

27!
217
    get thyMenuClassName(): string {
27✔
218
        return this.menuClassName;
219
    }
UNCOV
220

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

104✔
231
    get thyColumnClassName(): string {
104✔
232
        return this.columnClassName;
104✔
233
    }
234

235
    /**
104✔
236
     * 是否只读
27✔
237
     * @default false
81✔
238
     */
27✔
239
    @Input()
27✔
240
    // eslint-disable-next-line prettier/prettier
241
    override get thyDisabled(): boolean {
242
        return this.disabled;
243
    }
244

245
    override set thyDisabled(value: boolean) {
47✔
246
        this.disabled = coerceBooleanProperty(value);
247
    }
248

191✔
249
    disabled = false;
191✔
250

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

260
    /**
261
     * 是否多选
367!
262
     * @type boolean
263
     * @default false
264
     */
1,160!
265
    @Input()
1,160✔
266
    @InputBoolean()
1,160✔
267
    set thyMultiple(value: boolean) {
268
        this.isMultiple = value;
UNCOV
269
        this.initSelectionModel();
×
UNCOV
270
    }
×
271

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

×
UNCOV
276
    /**
×
277
     * 设置多选时最大显示的标签数量,0 表示不限制
278
     * @type number
×
279
     */
280
    @Input() @InputNumber() thyMaxTagCount = 0;
281

282
    /**
283
     * 是否仅允许选择叶子项
1,160✔
284
     * @default true
362✔
285
     */
286
    @Input()
798✔
287
    @InputBoolean()
288
    thyIsOnlySelectLeaf = true;
289

1,236✔
290
    /**
1,198✔
291
     * 是否支持搜索
273✔
292
     * @default false
293
     */
294
    @Input() @InputBoolean() thyShowSearch: boolean = false;
925✔
295

296
    /**
297
     * 值发生变化时触发,返回选择项的值
298
     * @type EventEmitter<any[]>
38✔
299
     */
38✔
UNCOV
300
    @Output() thyChange = new EventEmitter<any[]>();
×
UNCOV
301

×
UNCOV
302
    /**
×
303
     * 值发生变化时触发,返回选择项列表
304
     * @type EventEmitter<ThyCascaderOption[]>
UNCOV
305
     */
×
306
    @Output() thySelectionChange = new EventEmitter<ThyCascaderOption[]>();
307

308
    /**
38✔
309
     * 选择选项时触发
310
     */
311
    @Output() thySelect = new EventEmitter<{
312
        option: ThyCascaderOption;
29✔
313
        index: number;
29✔
314
    }>();
29✔
315

316
    /**
317
     * @private 暂无实现
318
     */
37✔
319
    @Output() thyDeselect = new EventEmitter<{
14✔
320
        option: ThyCascaderOption;
71✔
321
        index: number;
322
    }>();
323

14✔
324
    /**
29✔
325
     * 清空选项时触发
26✔
326
     */
26✔
327
    @Output() thyClear = new EventEmitter<void>();
328

329
    /**
330
     * 下拉选项展开和折叠状态事件
331
     */
332
    @Output() thyExpandStatusChange: EventEmitter<boolean> = new EventEmitter<boolean>();
106✔
333

106✔
334
    @ViewChildren('cascaderOptions', { read: ElementRef }) cascaderOptions: QueryList<ElementRef>;
103!
335

140✔
336
    @ViewChildren('cascaderOptionContainers', { read: ElementRef }) cascaderOptionContainers: QueryList<ElementRef>;
337

3✔
338
    @ViewChild(CdkConnectedOverlay, { static: true }) cdkConnectedOverlay: CdkConnectedOverlay;
339

340
    @ViewChild('trigger', { read: ElementRef, static: true }) trigger: ElementRef<any>;
112✔
341

122✔
342
    @ViewChild('input') input: ElementRef;
112✔
343

66✔
344
    @ViewChild('menu') menu: ElementRef;
345

346
    public dropDownPosition = 'bottom';
347
    public menuVisible = false;
46✔
348
    public isLoading = false;
4✔
349
    public showSearch = false;
350
    public labelRenderText: string;
351
    public labelRenderContext: any = {};
42✔
352
    public isLabelRenderTemplate = false;
42✔
353
    public triggerRect: DOMRect;
354
    public columns: ThyCascaderOption[][] = [];
46!
355
    public emptyStateText = '暂无可选项';
46✔
356

357
    public selectionModel: SelectionModel<SelectOptionBase>;
358
    private prefixCls = 'thy-cascader';
359
    private menuClassName: string;
360
    private columnClassName: string;
361
    private _menuColumnCls: any;
362
    private defaultValue: any[];
363
    private readonly destroy$ = new Subject<void>();
364
    private _menuCls: { [name: string]: any };
46✔
365
    private _labelCls: { [name: string]: any };
366
    private labelRenderTpl: TemplateRef<any>;
367
    private hostRenderer = useHostRenderer();
UNCOV
368
    private cascaderPosition: ConnectionPositionPair[];
×
369
    positions: ConnectionPositionPair[];
370

371
    private value: any[];
36✔
372

35✔
373
    private selectedOptions: ThyCascaderOption[] = [];
35✔
374

35✔
375
    private activatedOptions: ThyCascaderOption[] = [];
35✔
376

35✔
377
    get selected(): SelectOptionBase | SelectOptionBase[] {
30✔
378
        this.cdkConnectedOverlay?.overlayRef?.updatePosition();
379
        return this.thyMultiple ? this.selectionModel.selected : this.selectionModel.selected[0];
35✔
380
    }
381

382
    private isMultiple = false;
383

35✔
384
    private prevSelectedOptions: Set<ThyCascaderOption> = new Set<ThyCascaderOption>();
23✔
385

386
    public menuMinWidth = 122;
12✔
387

12✔
388
    private searchText$ = new BehaviorSubject('');
28✔
389

17✔
390
    public searchResultList: ThyCascaderSearchOption[] = [];
391

392
    public isShowSearchPanel: boolean = false;
393

394
    /**
241✔
395
     * 解决搜索&多选的情况下,点击搜索项会导致 panel 闪烁
396
     * 由于点击后,thySelectedOptions变化,导致 thySelectControl
397
     * 又会触发 searchFilter 函数,即 resetSearch 会执行
142✔
398
     * 会导致恢复级联状态再变为搜索状态
399
     */
400
    private isSelectingSearchState: boolean = false;
401

402
    private flattenOptions: ThyCascaderSearchOption[] = [];
403

404
    private leafNodes: ThyCascaderSearchOption[] = [];
405

428✔
406
    ngOnInit(): void {
407
        this.setClassMap();
408
        this.setMenuClass();
47✔
409
        this.setMenuColumnClass();
410
        this.setLabelClass();
411
        this.initPosition();
412
        this.initSearch();
413
        if (!this.selectionModel) {
414
            this.selectionModel = new SelectionModel<SelectOptionBase>(this.thyMultiple);
331✔
415
        }
416
        this.viewPortRuler
417
            .change(100)
47✔
418
            .pipe(takeUntil(this.destroy$))
419
            .subscribe(() => {
420
                if (this.menuVisible) {
421
                    this.triggerRect = this.trigger.nativeElement.getBoundingClientRect();
422
                    this.cdr.markForCheck();
423
                }
424
            });
82✔
425
    }
426

427
    private initSelectionModel() {
428
        if (this.selectionModel) {
429
            this.selectionModel.clear();
430
        } else {
431
            this.selectionModel = new SelectionModel(this.isMultiple);
82✔
432
        }
433
    }
434

28✔
435
    private initPosition() {
20✔
436
        this.cascaderPosition = EXPANDED_DROPDOWN_POSITIONS.map(item => {
437
            return { ...item };
8✔
438
        });
439
        this.cascaderPosition[0].offsetY = 4; // 左下
440
        this.cascaderPosition[1].offsetY = 4; // 右下
4!
441
        this.cascaderPosition[2].offsetY = -4; // 右下
4✔
442
        this.cascaderPosition[3].offsetY = -4; // 右下
UNCOV
443
        this.positions = this.cascaderPosition;
×
444
    }
445

446
    private initOptions(index: number) {
2!
447
        const vs = this.defaultValue;
2✔
448
        const load = () => {
UNCOV
449
            this.activateOnInit(index, vs[index]);
×
450
            if (index < vs.length - 1) {
451
                this.initOptions(index + 1);
452
            }
30✔
453
            if (index === vs.length - 1) {
2✔
454
                this.afterWriteValue();
455
            }
28!
456
        };
28✔
457

458
        if (this.isLoaded(index) || !this.thyLoadData) {
459
            load();
460
        } else {
2!
UNCOV
461
            const node = this.activatedOptions[index - 1] || {};
×
462
            this.loadChildren(node, index - 1, load, this.afterWriteValue.bind(this));
463
        }
2!
464
    }
2✔
465

466
    private activateOnInit(index: number, value: any): void {
467
        let option = this.findOption(value, index);
468
        if (!option) {
20!
UNCOV
469
            option =
×
470
                typeof value === 'object'
471
                    ? value
20✔
472
                    : {
20✔
473
                          [`${this.thyValueProperty || 'value'}`]: value,
1✔
474
                          [`${this.thyLabelProperty || 'label'}`]: value
475
                      };
476
        }
19✔
477
        this.updatePrevSelectedOptions(option, true);
478
        this.setActiveOption(option, index, false, false);
479
    }
480

1✔
481
    private updatePrevSelectedOptions(option: ThyCascaderOption, isActivateInit: boolean, index?: number) {
1✔
482
        if (isActivateInit) {
1✔
483
            if (this.thyIsOnlySelectLeaf && option.isLeaf) {
3✔
484
                set(option, 'selected', true);
3✔
485
            }
3✔
486
            this.prevSelectedOptions.add(option);
487
        } else {
1✔
488
            if (!this.thyMultiple) {
3✔
489
                const prevSelectedOptions = Array.from(this.prevSelectedOptions);
3✔
490
                while (prevSelectedOptions.length) {
2✔
491
                    set(prevSelectedOptions.pop(), 'selected', false);
492
                }
493
                this.prevSelectedOptions = new Set([]);
494
            }
495
            if (this.thyIsOnlySelectLeaf && !option.isLeaf && this.thyMultiple) {
1✔
496
                set(option, 'selected', this.isSelectedOption(option, index));
1!
497
            } else {
1✔
498
                set(option, 'selected', !this.isSelectedOption(option, index));
4✔
499
            }
4✔
500
            if (this.thyIsOnlySelectLeaf && this.thyMultiple && option.parent) {
3✔
501
                this.updatePrevSelectedOptions(option.parent, false, index - 1);
502
            }
503
            this.prevSelectedOptions.add(option);
504
        }
505
    }
1!
UNCOV
506

×
507
    writeValue(value: any): void {
508
        if (!this.selectionModel) {
509
            this.initSelectionModel();
510
        }
1✔
511
        if (!this.isMultiple) {
512
            const vs = (this.defaultValue = toArray(value));
513
            if (vs.length) {
514
                this.initOptions(0);
515
            } else {
516
                this.value = vs;
517
                this.activatedOptions = [];
518
                this.afterWriteValue();
519
            }
3,167✔
520
        } else {
3,383✔
521
            const values = toArray(value);
700✔
522
            this.selectionModel.clear();
523
            values.forEach(item => {
2,683✔
524
                const vs = (this.defaultValue = toArray(item));
1,108✔
525
                if (vs.length) {
526
                    this.initOptions(0);
527
                } else {
1,359✔
528
                    this.value = vs;
529
                    this.activatedOptions = [];
530
                    this.afterWriteValue();
2!
531
                }
2✔
532
            });
533
            this.cdr.detectChanges();
2!
UNCOV
534
        }
×
535
    }
536

2!
537
    afterWriteValue(): void {
1✔
538
        this.selectedOptions = this.activatedOptions;
539
        this.value = this.getSubmitValue(this.selectedOptions);
1✔
540
        this.addSelectedState(this.selectedOptions);
541
        this.buildDisplayLabel();
542
    }
2!
543

2✔
544
    private addSelectedState(selectOptions: ThyCascaderOption[]) {
545
        if (this.isMultiple && this.thyIsOnlySelectLeaf) {
2✔
546
            selectOptions.forEach(opt => {
1✔
547
                if (opt.isLeaf) {
548
                    opt.selected = true;
1✔
549
                    set(opt, 'selected', true);
550
                }
551
            });
552
        }
1!
UNCOV
553
    }
×
554

555
    setDisabledState(isDisabled: boolean): void {
1✔
556
        this.disabled = isDisabled;
557
    }
558

1!
559
    public positionChange(position: ConnectedOverlayPositionChange): void {
1✔
560
        const newValue = position.connectionPair.originY === 'bottom' ? 'bottom' : 'top';
1✔
561
        if (this.dropDownPosition !== newValue) {
562
            this.dropDownPosition = newValue;
563
            this.cdr.detectChanges();
564
        }
5!
UNCOV
565
    }
×
UNCOV
566

×
UNCOV
567
    private isLoaded(index: number): boolean {
×
UNCOV
568
        return this.columns[index] && this.columns[index].length > 0;
×
569
    }
570

571
    public getOptionLabel(option: ThyCascaderOption): any {
26✔
572
        return option[this.thyLabelProperty || 'label'];
132✔
573
    }
132✔
574

116✔
575
    public getOptionValue(option: ThyCascaderOption): any {
116✔
576
        return option[this.thyValueProperty || 'value'];
3✔
577
    }
578

579
    public isActivatedOption(option: ThyCascaderOption, index: number): boolean {
132✔
580
        if (!this.isMultiple || this.thyIsOnlySelectLeaf) {
19✔
581
            const activeOpt = this.activatedOptions[index];
582
            return activeOpt === option;
132✔
583
        } else {
79✔
584
            if (option.isLeaf) {
88✔
585
                return option.selected;
79✔
586
            } else {
587
                const selectedOpts = this.selectionModel.selected;
53✔
588
                const appearIndex = selectedOpts.findIndex(item => {
3✔
589
                    const selectedItem = helpers.get(item, `thyRawValue.value.${index}`);
590
                    return helpers.shallowEqual(selectedItem, option);
591
                });
50!
UNCOV
592
                return appearIndex >= 0;
×
593
            }
594
        }
595
    }
132✔
596

9✔
597
    public isHalfSelectedOption(option: ThyCascaderOption, index: number): boolean {
598
        if (!option.selected && this.thyIsOnlySelectLeaf && !option.isLeaf && !this.checkSelectedStatus(option, false)) {
599
            return true;
600
        }
9✔
601
        return false;
9✔
602
    }
9!
603

9✔
604
    public isSelectedOption(option: ThyCascaderOption, index: number): boolean {
9✔
605
        if (this.thyIsOnlySelectLeaf) {
9✔
606
            if (option.isLeaf) {
8✔
607
                return option.selected;
608
            } else {
609
                return this.checkSelectedStatus(option, true);
1✔
610
            }
1✔
611
        } else {
1!
612
            const selectedOpts = this.selectionModel.selected;
1✔
613
            const appearIndex = selectedOpts.findIndex(item => {
1✔
614
                if (item.thyRawValue.value.length - 1 === index) {
615
                    const selectedItem = helpers.get(item, `thyRawValue.value.${index}`);
UNCOV
616
                    return helpers.shallowEqual(selectedItem, option);
×
617
                } else {
618
                    return false;
619
                }
1✔
620
            });
621
            return appearIndex >= 0;
9✔
622
        }
623
    }
9✔
624

4✔
625
    public attached(): void {
4✔
626
        this.cdr.detectChanges();
627
        this.cdkConnectedOverlay.positionChange.pipe(take(1), takeUntil(this.destroy$)).subscribe(() => {
628
            this.scrollActiveElementIntoView();
629
        });
1✔
630
    }
1✔
631

1✔
632
    private scrollActiveElementIntoView() {
1✔
633
        if (!isEmpty(this.selectedOptions)) {
634
            const activeOptions = this.cascaderOptions
1✔
635
                .filter(item => item.nativeElement.classList.contains('thy-cascader-menu-item-active'))
1✔
636
                // for multiple mode
637
                .slice(-this.cascaderOptionContainers.length);
1✔
638

1!
639
            this.cascaderOptionContainers.forEach((item, index) => {
1✔
640
                if (index <= activeOptions.length - 1) {
641
                    ScrollToService.scrollToElement(activeOptions[index].nativeElement, item.nativeElement);
1✔
642
                    this.cdr.detectChanges();
643
                }
644
            });
2✔
645
        }
2✔
646
    }
4✔
647

1✔
648
    private findOption(option: any, index: number): ThyCascaderOption {
649
        const options: ThyCascaderOption[] = this.columns[index];
650
        if (options) {
651
            const value = typeof option === 'object' ? this.getOptionValue(option) : option;
UNCOV
652
            return options.find(o => value === this.getOptionValue(o));
×
653
        }
654
        return null;
655
    }
11✔
656

11!
657
    private buildDisplayLabel(): void {
11✔
658
        const selectedOptions = [...this.selectedOptions];
11✔
659
        const labels: string[] = selectedOptions.map(o => this.getOptionLabel(o));
11✔
660
        if (labels.length === 0) {
11✔
661
            return;
1✔
662
        }
663
        let labelRenderContext;
11✔
664
        let labelRenderText;
11✔
665
        if (this.isLabelRenderTemplate) {
666
            labelRenderContext = { labels, selectedOptions };
667
        } else {
668
            labelRenderText = defaultDisplayRender.call(this, labels, selectedOptions);
669
            this.labelRenderText = labelRenderText;
11✔
670
        }
24✔
671
        if (this.labelRenderText || this.isLabelRenderTemplate) {
11✔
672
            const selectedData: SelectOptionBase = {
673
                thyRawValue: {
674
                    value: selectedOptions,
1!
675
                    labelText: labelRenderText,
1✔
676
                    labelRenderContext: labelRenderContext
1✔
677
                },
678
                thyValue: labels,
1✔
679
                thyLabelText: labelRenderText
1✔
680
            };
1✔
681
            this.selectionModel.select(selectedData);
1✔
682
        }
1✔
683
    }
1✔
684

1✔
685
    public isMenuVisible(): boolean {
686
        return this.menuVisible;
687
    }
1✔
688

1✔
689
    public setMenuVisible(menuVisible: boolean): void {
1✔
690
        if (this.menuVisible !== menuVisible) {
691
            this.menuVisible = menuVisible;
692

5✔
693
            this.initActivatedOptions();
4✔
694
            this.setClassMap();
4✔
695
            this.setMenuClass();
3✔
696
            if (this.menuVisible) {
3!
697
                this.triggerRect = this.trigger.nativeElement.getBoundingClientRect();
4✔
698
            }
3✔
699
            this.thyExpandStatusChange.emit(menuVisible);
700
        }
3✔
701
    }
1✔
702

703
    private initActivatedOptions() {
704
        if (isEmpty(this.selectedOptions) || !this.menuVisible) {
1✔
705
            return;
1✔
706
        }
1!
707
        this.activatedOptions = [...this.selectedOptions];
1✔
708
        this.activatedOptions.forEach((item, index) => {
709
            if (!isEmpty(item.children) && !item.isLeaf) {
710
                this.columns[index + 1] = item.children;
711
            }
712
        });
1!
713
    }
714

715
    public get menuCls(): any {
716
        return this._menuCls;
85✔
717
    }
81✔
718

81✔
719
    private setMenuClass(): void {
18✔
720
        this._menuCls = {
721
            [`${this.prefixCls}-menus`]: true,
722
            [`${this.prefixCls}-menus-hidden`]: !this.menuVisible,
723
            [`${this.thyMenuClassName}`]: this.thyMenuClassName,
724
            [`w-100`]: this.columns.length === 0
128✔
725
        };
128!
726
    }
170✔
727

728
    public get menuColumnCls(): any {
128✔
729
        return this._menuColumnCls;
730
    }
731

47✔
732
    private setMenuColumnClass(): void {
47✔
733
        this._menuColumnCls = {
47✔
734
            [`${this.prefixCls}-menu`]: true,
47✔
735
            [`${this.thyColumnClassName}`]: this.thyColumnClassName
47✔
736
        };
47✔
737
    }
47✔
738

47✔
739
    public get labelCls(): any {
47✔
740
        return this._labelCls;
47✔
741
    }
47✔
742

47✔
743
    private setLabelClass(): void {
47✔
744
        this._labelCls = {
47✔
745
            [`${this.prefixCls}-picker-label`]: true,
47✔
746
            [`${this.prefixCls}-show-search`]: false,
47✔
747
            [`${this.prefixCls}-focused`]: false
47✔
748
        };
47✔
749
    }
47✔
750

47✔
751
    private setClassMap(): void {
47✔
752
        const classMap = {
47✔
753
            [`${this.prefixCls}`]: true,
47✔
754
            [`${this.prefixCls}-picker`]: true,
47✔
755
            [`${this.prefixCls}-${this.thySize}`]: true,
47✔
756
            [`${this.prefixCls}-picker-disabled`]: this.disabled,
47✔
757
            [`${this.prefixCls}-picker-open`]: this.menuVisible
47✔
758
        };
47✔
759
        this.hostRenderer.updateClassByMap(classMap);
47✔
760
    }
47✔
761

47✔
762
    private isClickTriggerAction(): boolean {
47✔
763
        if (typeof this.thyTriggerAction === 'string') {
47✔
764
            return this.thyTriggerAction === 'click';
47✔
765
        }
47✔
766
        return this.thyTriggerAction.indexOf('click') !== -1;
47✔
767
    }
47✔
768

47✔
769
    private isHoverTriggerAction(): boolean {
47✔
770
        if (typeof this.thyTriggerAction === 'string') {
47✔
771
            return this.thyTriggerAction === 'hover';
47✔
772
        }
773
        return this.thyTriggerAction.indexOf('hover') !== -1;
774
    }
775

776
    private isHoverExpandTriggerAction(): boolean {
777
        if (typeof this.thyExpandTriggerAction === 'string') {
778
            return this.thyExpandTriggerAction === 'hover';
47✔
779
        }
47✔
780
        return this.thyExpandTriggerAction.indexOf('hover') !== -1;
47✔
781
    }
782

783
    @HostListener('click', ['$event'])
789!
784
    public toggleClick($event: Event) {
785
        if (this.disabled) {
786
            return;
7✔
787
        }
1✔
788
        if (this.isClickTriggerAction()) {
789
            this.setMenuVisible(!this.menuVisible);
7✔
790
        }
791
    }
792

47✔
793
    @HostListener('mouseover', ['$event'])
48✔
794
    public toggleHover($event: Event) {
795
        if (this.disabled) {
6✔
796
            return;
797
        }
6✔
798
        if (this.isHoverTriggerAction()) {
6✔
799
            this.setMenuVisible(!this.menuVisible);
800
        }
801
    }
18✔
802

38✔
803
    public clickOption(option: ThyCascaderOption, index: number, event: Event | boolean): void {
57✔
804
        if (option && option.disabled && !this.isMultiple) {
57✔
805
            return;
57✔
806
        }
57✔
807
        const isSelect = event instanceof Event ? !this.isMultiple && option.isLeaf : true;
57✔
808
        if (this.isMultiple && !option.isLeaf && this.thyIsOnlySelectLeaf && isSelect) {
57✔
809
            this.toggleAllChildren(option, index, event as boolean);
57✔
810
        } else {
811
            this.setActiveOption(option, index, isSelect);
812
        }
813
    }
814

815
    private toggleAllChildren(option: ThyCascaderOption, index: number, selected: boolean): void {
816
        const allLeafs: {
817
            option: ThyCascaderOption;
57✔
818
            index: number;
32✔
819
        }[] = this.getAllLeafs(option, index, selected);
820
        option.selected = selected;
821
        while (allLeafs.length) {
25✔
822
            const { option, index } = allLeafs.shift();
823
            option.selected = !selected;
824
            this.setActiveOption(option, index, true);
825
        }
826
        for (let i = 0; i < this.activatedOptions.length; i++) {
827
            const option = this.activatedOptions[i];
828
            if (isArray(option.children) && option.children.length) {
829
                this.setColumnData(option.children, i + 1);
830
            }
831
        }
832
    }
833

6✔
834
    private getAllLeafs(
6✔
835
        option: ThyCascaderOption,
29✔
836
        index: number,
4✔
837
        selected: boolean
838
    ): {
839
        option: ThyCascaderOption;
840
        index: number;
841
    }[] {
6✔
842
        let allLeafs: {
6✔
843
            option: ThyCascaderOption;
844
            index: number;
845
        }[] = [];
8✔
846
        if (option.children.length > 0) {
8✔
847
            for (const childOption of option.children) {
8✔
848
                childOption.parent = option;
8✔
849
                if (childOption.isLeaf && !childOption.selected === selected) {
8✔
850
                    allLeafs.push({
851
                        option: childOption,
852
                        index: index + 1
1✔
853
                    });
1!
UNCOV
854
                } else if (!childOption.isLeaf) {
×
UNCOV
855
                    allLeafs = allLeafs.concat(this.getAllLeafs(childOption, index + 1, selected));
×
856
                }
UNCOV
857
            }
×
858
        }
859
        return allLeafs;
1!
UNCOV
860
    }
×
UNCOV
861

×
UNCOV
862
    /**
×
UNCOV
863
     * 检查所有所有子项的选择状态, 有一个不符合预期,就直接返回 false
×
864
     * @param option
UNCOV
865
     * @param trueOrFalse
×
866
     * @private
UNCOV
867
     */
×
UNCOV
868
    private checkSelectedStatus(option: ThyCascaderOption, isSelected: boolean): boolean {
×
UNCOV
869
        for (const childOption of option.children) {
×
UNCOV
870
            if (isArray(childOption.children) && childOption.children.length && !this.checkSelectedStatus(childOption, isSelected)) {
×
871
                return false;
872
            }
873
            if (!childOption.selected === isSelected) {
874
                return false;
1✔
875
            }
3✔
876
        }
877
        return true;
1✔
878
    }
879

880
    public mouseoverOption(option: ThyCascaderOption, index: number, event: Event): void {
881
        if (event) {
47✔
882
            event.preventDefault();
47✔
883
        }
884

1✔
885
        if (option && option.disabled && !this.isMultiple) {
886
            return;
887
        }
888

889
        if (!this.isHoverExpandTriggerAction() && !(option && option.disabled && this.isMultiple)) {
1✔
890
            return;
891
        }
892
        this.setActiveOption(option, index, false);
893
    }
894

895
    public mouseleaveMenu(event: Event) {
896
        if (event) {
897
            event.preventDefault();
898
        }
899
        if (!this.isHoverTriggerAction()) {
900
            return;
901
        }
902
        this.setMenuVisible(!this.menuVisible);
903
    }
904

905
    onBlur(event?: FocusEvent) {
906
        // Tab 聚焦后自动聚焦到 input 输入框,此分支下直接返回,无需触发 onTouchedFn
907
        if (elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-cascader-menus', 'thy-cascader'])) {
908
            return;
909
        }
910
        this.onTouchedFn();
911
    }
912

913
    onFocus(event?: FocusEvent) {
914
        if (!elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-cascader-menus', 'thy-cascader'])) {
915
            const inputElement: HTMLInputElement = this.elementRef.nativeElement.querySelector('input');
916
            inputElement.focus();
917
        }
918
    }
919

920
    public closeMenu(): void {
921
        if (this.menuVisible) {
922
            this.setMenuVisible(false);
923
            this.onTouchedFn();
924
            this.isShowSearchPanel = false;
925
            this.searchResultList = [];
926
        }
927
    }
1✔
928

929
    public setActiveOption(option: ThyCascaderOption, index: number, select: boolean, loadChildren: boolean = true): void {
930
        this.activatedOptions[index] = option;
931
        for (let i = index - 1; i >= 0; i--) {
1✔
932
            const originOption = this.activatedOptions[i + 1]?.parent;
933
            if (!this.activatedOptions[i] || originOption?.[this.thyValueProperty] !== this.activatedOptions[i]?.[this.thyValueProperty]) {
934
                this.activatedOptions[i] = originOption ?? this.activatedOptions[i];
935
            }
1✔
936
        }
937
        if (index < this.activatedOptions.length - 1) {
938
            this.activatedOptions = this.activatedOptions.slice(0, index + 1);
939
        }
940
        if (isArray(option.children) && option.children.length) {
1✔
941
            option.isLeaf = false;
942
            option.children.forEach(child => (child.parent = option));
943
            this.setColumnData(option.children, index + 1);
944
        } else if (!option.isLeaf && loadChildren) {
1✔
945
            this.loadChildren(option, index);
946
        } else {
947
            if (index < this.columns.length - 1) {
948
                this.columns = this.columns.slice(0, index + 1);
1✔
949
            }
950
        }
951
        if (select) {
952
            this.selectOption(option, index);
1✔
953
        }
954
    }
955

956
    private selectOption(option: ThyCascaderOption, index: number): void {
957
        this.thySelect.emit({ option, index });
958
        const isOptionCanSelect = this.thyChangeOnSelect && !this.isMultiple;
959
        if (option.isLeaf || !this.thyIsOnlySelectLeaf || isOptionCanSelect || this.shouldPerformSelection(option, index)) {
47✔
960
            this.selectedOptions = this.activatedOptions;
961
            this.updatePrevSelectedOptions(option, false, index);
962
            if (option.selected) {
963
                this.buildDisplayLabel();
964
            } else {
965
                const selectedItems = this.selectionModel.selected;
966
                const currentItem = selectedItems.find(item => {
967
                    if (item.thyRawValue.value.length - 1 === index) {
968
                        const selectedItem = helpers.get(item, `thyRawValue.value.${index}`);
969
                        return helpers.shallowEqual(selectedItem, option);
970
                    } else {
971
                        return false;
972
                    }
973
                });
974
                this.selectionModel.deselect(currentItem);
975
            }
976
            this.valueChange();
977
        }
978
        if ((option.isLeaf || !this.thyIsOnlySelectLeaf) && !this.thyMultiple) {
979
            this.setMenuVisible(false);
980
            this.onTouchedFn();
981
        }
982
    }
983

984
    public removeSelectedItem(event: { item: SelectOptionBase; $eventOrigin: Event }) {
985
        const selectedItems = this.selectionModel.selected;
986
        event.$eventOrigin.stopPropagation();
987
        const currentItem = selectedItems.find(item => {
988
            return helpers.shallowEqual(item.thyValue, event.item.thyValue);
989
        });
990
        this.deselectOption(currentItem);
991
        this.selectionModel.deselect(currentItem);
992
        // update selectedOptions
993
        const updatedSelectedItems = this.selectionModel.selected;
994
        if (isArray(updatedSelectedItems) && updatedSelectedItems.length) {
995
            this.selectedOptions = updatedSelectedItems[updatedSelectedItems.length - 1].thyRawValue.value;
996
        }
997
        this.valueChange();
998
    }
999

1000
    private deselectOption(option: SelectOptionBase) {
1001
        const value: ThyCascaderOption[] = option.thyRawValue.value;
1002
        value.forEach(item => {
1003
            if (item.isLeaf && item.selected) {
1004
                set(item, 'selected', false);
1005
            }
1006
        });
1007
    }
1008

1009
    private shouldPerformSelection(option: ThyCascaderOption, level: number): boolean {
1010
        return typeof this.thyChangeOn === 'function' ? this.thyChangeOn(option, level) === true : false;
1011
    }
1012

1013
    private valueChange(): void {
1014
        const value = this.getValues();
1015
        if (!arrayEquals(this.value, value)) {
1016
            this.defaultValue = null;
1017
            this.value = value;
1018
            this.onChangeFn(value);
1019
            if (this.selectionModel.isEmpty()) {
1020
                this.thyClear.emit();
1021
            }
1022
            this.thySelectionChange.emit(this.selectedOptions);
1023
            this.thyChange.emit(value);
1024
        }
1025
    }
1026

1027
    private getValues() {
1028
        let selectedItems: any[];
1029
        const selected = this.selectionModel.selected;
1030
        selectedItems = selected.map(item => this.getSubmitValue(item.thyRawValue.value));
1031
        return this.isMultiple ? selectedItems : selectedItems[0] ?? selectedItems;
1032
    }
1033

1034
    public clearSelection($event: Event): void {
1035
        if ($event) {
1036
            $event.stopPropagation();
1037
            $event.preventDefault();
1038
        }
1039
        this.labelRenderText = '';
1040
        this.labelRenderContext = {};
1041
        this.selectedOptions = [];
1042
        this.activatedOptions = [];
1043
        this.deselectAllSelected();
1044
        this.setMenuVisible(false);
1045
        this.valueChange();
1046
    }
1047

1048
    private deselectAllSelected() {
1049
        const selectedOptions = this.selectionModel.selected;
1050
        selectedOptions.forEach(item => this.deselectOption(item));
1051
        this.selectionModel.clear();
1052
    }
1053

1054
    private loadChildren(option: ThyCascaderOption, index: number, success?: () => void, failure?: () => void): void {
1055
        if (this.thyLoadData) {
1056
            this.isLoading = true;
1057
            this.thyLoadData(option, index).then(
1058
                () => {
1059
                    option.loading = this.isLoading = false;
1060
                    if (option.children) {
1061
                        option.children.forEach(child => (child.parent = index < 0 ? undefined : option));
1062
                        this.setColumnData(option.children, index + 1);
1063
                    }
1064
                    if (success) {
1065
                        success();
1066
                    }
1067
                },
1068
                () => {
1069
                    option.loading = this.isLoading = false;
1070
                    option.isLeaf = true;
1071
                    if (failure) {
1072
                        failure();
1073
                    }
1074
                }
1075
            );
1076
        } else {
1077
            this.setColumnData(option.children || [], index + 1);
1078
        }
1079
    }
1080

1081
    private setColumnData(options: ThyCascaderOption[], index: number): void {
1082
        if (!arrayEquals(this.columns[index], options)) {
1083
            this.columns[index] = options;
1084
            if (index < this.columns.length - 1) {
1085
                this.columns = this.columns.slice(0, index + 1);
1086
            }
1087
        }
1088
    }
1089

1090
    private getSubmitValue(originOptions: ThyCascaderOption[]): any[] {
1091
        const values: any[] = [];
1092
        (originOptions || []).forEach(option => {
1093
            values.push(this.getOptionValue(option));
1094
        });
1095
        return values;
1096
    }
1097

1098
    constructor(private cdr: ChangeDetectorRef, private viewPortRuler: ViewportRuler, public elementRef: ElementRef) {
1099
        super();
1100
    }
1101

1102
    public trackByFn(index: number, item: ThyCascaderOption) {
1103
        return item?.value || item?._id || index;
1104
    }
1105

1106
    public searchFilter(searchText: string) {
1107
        if (!searchText && !this.isSelectingSearchState) {
1108
            this.resetSearch();
1109
        }
1110
        this.searchText$.next(searchText);
1111
    }
1112

1113
    private initSearch() {
1114
        this.searchText$
1115
            .pipe(
1116
                takeUntil(this.destroy$),
1117
                debounceTime(200),
1118
                distinctUntilChanged(),
1119
                filter(text => text !== '')
1120
            )
1121
            .subscribe(searchText => {
1122
                this.resetSearch();
1123

1124
                // local search
1125
                this.searchInLocal(searchText);
1126
                this.isShowSearchPanel = true;
1127
            });
1128
    }
1129

1130
    private forEachColumns(
1131
        currentLabel?: string[],
1132
        currentValue: Id[] = [],
1133
        currentRowValue: ThyCascaderOption[] = [],
1134
        list = this.columns[0]
1135
    ) {
1136
        list.forEach(item => {
1137
            const curOptionLabel = this.getOptionLabel(item);
1138
            const curOptionValue = this.getOptionValue(item);
1139
            const label: string[] = currentLabel ? [...currentLabel, curOptionLabel] : [curOptionLabel];
1140
            const valueList: Id[] = [...currentValue, curOptionValue];
1141
            const rowValueList: ThyCascaderOption[] = [...currentRowValue, item];
1142
            const isSelected = this.isSelectedOption(item, valueList.length - 1);
1143

1144
            this.flattenOptions.push({
1145
                labelList: label,
1146
                valueList,
1147
                selected: isSelected,
1148
                thyRowValue: rowValueList,
1149
                isLeaf: item.isLeaf,
1150
                disabled: item.disabled
1151
            });
1152
            if (item.children && item.children.length) {
1153
                this.forEachColumns(label, valueList, rowValueList, item.children);
1154
            } else {
1155
                this.leafNodes.push({
1156
                    labelList: label,
1157
                    valueList,
1158
                    selected: isSelected,
1159
                    thyRowValue: rowValueList,
1160
                    isLeaf: item.isLeaf,
1161
                    disabled: item.disabled
1162
                });
1163
            }
1164
        });
1165
    }
1166

1167
    private setSearchResultList(listOfOption: ThyCascaderSearchOption[], searchText: string) {
1168
        this.searchResultList = [];
1169
        listOfOption.forEach(item => {
1170
            if (!item.disabled && item.isLeaf && item.labelList.join().toLowerCase().indexOf(searchText.toLowerCase()) !== -1) {
1171
                this.searchResultList.push(item);
1172
            }
1173
        });
1174
    }
1175

1176
    private searchInLocal(searchText: string): void {
1177
        this.forEachColumns();
1178

1179
        this.setSearchResultList(this.thyIsOnlySelectLeaf ? this.leafNodes : this.flattenOptions, searchText);
1180
    }
1181

1182
    private resetSearch() {
1183
        this.isShowSearchPanel = false;
1184
        this.searchResultList = [];
1185
        this.leafNodes = [];
1186
        this.flattenOptions = [];
1187
        this.scrollActiveElementIntoView();
1188
    }
1189

1190
    public selectSearchResult(selectOptionData: ThyCascaderSearchOption): void {
1191
        const { thyRowValue: selectedOptions } = selectOptionData;
1192
        if (selectOptionData.selected) {
1193
            if (!this.isMultiple) {
1194
                this.closeMenu();
1195
            }
1196
            return;
1197
        }
1198
        if (this.isMultiple) {
1199
            this.isSelectingSearchState = true;
1200
            selectOptionData.selected = true;
1201
            selectedOptions.forEach((item: ThyCascaderOption, index: number) => {
1202
                this.setActiveOption(item, index, index === selectedOptions.length - 1);
1203
            });
1204
            const originSearchResultList = this.searchResultList;
1205
            // 保持搜索选项
1206
            setTimeout(() => {
1207
                this.isShowSearchPanel = true;
1208
                this.searchResultList = originSearchResultList;
1209
                this.isSelectingSearchState = false;
1210
            });
1211
        } else {
1212
            selectedOptions.forEach((item: ThyCascaderOption, index: number) => {
1213
                this.setActiveOption(item, index, index === selectedOptions.length - 1);
1214
            });
1215

1216
            this.resetSearch();
1217
        }
1218
    }
1219

1220
    ngOnDestroy() {
1221
        this.destroy$.next();
1222
        this.destroy$.complete();
1223
    }
1224
}
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