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

atinc / ngx-tethys / f8bbedfe-aeb4-43c7-84ab-8ae708228cc3

08 Nov 2023 03:16AM UTC coverage: 90.166% (-0.02%) from 90.182%
f8bbedfe-aeb4-43c7-84ab-8ae708228cc3

push

circleci

web-flow
fix(cascade): fix trigger two changes #INFR_10249 (#2888)

5220 of 6457 branches covered (0.0%)

Branch coverage included in aggregate %.

13135 of 13900 relevant lines covered (94.5%)

978.8 hits per line

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

85.94
/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, helpers, isArray, isEmpty, set } 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
122✔
23
} from '@angular/cdk/overlay';
76✔
24
import { NgClass, NgFor, NgIf, NgStyle, NgTemplateOutlet } from '@angular/common';
25
import {
46✔
26
    ChangeDetectorRef,
6✔
27
    Component,
28
    ElementRef,
29
    EventEmitter,
40✔
30
    forwardRef,
31
    HostListener,
122✔
32
    Input,
33
    OnDestroy,
34
    OnInit,
79✔
35
    Output,
64✔
36
    QueryList,
37
    TemplateRef,
15✔
38
    ViewChild,
15✔
39
    ViewChildren
15!
40
} from '@angular/core';
15✔
41
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
42
import { useHostRenderer } from '@tethys/cdk/dom';
43

×
44
import { ThyCascaderOptionComponent } from './cascader-li.component';
45
import { ThyCascaderSearchOptionComponent } from './cascader-search-option.component';
34✔
46
import { ThyCascaderExpandTrigger, ThyCascaderOption, ThyCascaderSearchOption, ThyCascaderTriggerType } from './types';
47

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

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

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

30✔
74
const defaultDisplayRender = (label: any) => label.join(' / ');
75

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

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

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

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

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

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

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

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

170
    /**
97✔
171
     * 用户自定义模板
91✔
172
     * @type TemplateRef
91✔
173
     */
174
    @Input()
175
    set thyLabelRender(value: TemplateRef<any>) {
6✔
176
        this.labelRenderTpl = value;
6✔
177
        this.isLabelRenderTemplate = value instanceof TemplateRef;
×
178
    }
179

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

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

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

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

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

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

216
    get thyMenuClassName(): string {
99✔
217
        return this.menuClassName;
99✔
218
    }
99✔
219

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

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

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

244
    override set thyDisabled(value: boolean) {
245
        this.disabled = coerceBooleanProperty(value);
152!
246
    }
247

248
    disabled = false;
285!
249

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

259
    /**
260
     * 是否多选
456✔
261
     * @type boolean
456✔
262
     * @default false
766✔
263
     */
766✔
264
    @Input()
265
    @InputBoolean()
456✔
266
    set thyMultiple(value: boolean) {
267
        this.isMultiple = value;
268
        this.initSelectionModel();
269
    }
270

1,026✔
271
    get thyMultiple(): boolean {
988✔
272
        return this.isMultiple;
185✔
273
    }
274

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

×
281
    /**
282
     * 是否仅允许选择叶子项
283
     * @default true
×
284
     */
285
    @Input()
286
    @InputBoolean()
38✔
287
    thyIsOnlySelectLeaf = true;
288

289
    /**
290
     * 是否支持搜索
28✔
291
     * @default false
28✔
292
     */
28✔
293
    @Input() @InputBoolean() thyShowSearch: boolean = false;
294

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

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

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

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

323
    /**
324
     * 清空选项时触发
325
     */
38✔
326
    @Output() thyClear = new EventEmitter<void>();
4✔
327

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

38!
333
    @ViewChildren('cascaderOptions', { read: ElementRef }) cascaderOptions: QueryList<ElementRef>;
38✔
334

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

337
    @ViewChild(CdkConnectedOverlay, { static: true }) cdkConnectedOverlay: CdkConnectedOverlay;
338

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

341
    @ViewChild('input') input: ElementRef;
342

38✔
343
    @ViewChild('menu') menu: ElementRef;
344

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

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

370
    private value: any[];
371

372
    private selectedOptions: ThyCascaderOption[] = [];
225✔
373

374
    private activatedOptions: ThyCascaderOption[] = [];
375

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

381
    private isMultiple = false;
382

383
    private prevSelectedOptions: ThyCascaderOption[] = [];
380✔
384

385
    public menuMinWidth = 122;
386

46✔
387
    private searchText$ = new BehaviorSubject('');
388

389
    public searchResultList: ThyCascaderSearchOption[] = [];
390

391
    public isShowSearchPanel: boolean = false;
392

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

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

80✔
403
    private leafNodes: ThyCascaderSearchOption[] = [];
404

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

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

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

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

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

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

480
    private updatePrevSelectedOptions(option: ThyCascaderOption, isActivateInit: boolean, index?: number) {
481
        if (isActivateInit) {
1!
482
            set(option, 'selected', true);
1✔
483
            this.prevSelectedOptions.push(option);
1✔
484
        } else {
485
            const isSelected = !this.isSelectedOption(option, index);
486
            while (this.prevSelectedOptions.length && !this.thyMultiple) {
487
                set(this.prevSelectedOptions.pop(), 'selected', false);
5!
488
            }
×
489
            set(option, 'selected', isSelected);
×
490
            this.prevSelectedOptions.push(option);
×
491
        }
×
492
    }
493

494
    writeValue(value: any): void {
21✔
495
        if (!this.selectionModel) {
112✔
496
            this.initSelectionModel();
112✔
497
        }
94✔
498
        if (!this.isMultiple) {
94!
499
            const vs = (this.defaultValue = toArray(value));
×
500
            if (vs.length) {
501
                this.initOptions(0);
502
            } else {
112✔
503
                this.value = vs;
14✔
504
                this.activatedOptions = [];
505
                this.afterWriteValue();
112✔
506
            }
67✔
507
        } else {
67✔
508
            const values = toArray(value);
67✔
509
            this.selectionModel.clear();
510
            values.forEach(item => {
45✔
511
                const vs = (this.defaultValue = toArray(item));
3✔
512
                if (vs.length) {
513
                    this.initOptions(0);
514
                } else {
42!
515
                    this.value = vs;
×
516
                    this.activatedOptions = [];
517
                    this.afterWriteValue();
518
                }
112✔
519
            });
6✔
520
            this.cdr.detectChanges();
521
        }
522
    }
523

6✔
524
    afterWriteValue(): void {
6✔
525
        this.selectedOptions = this.activatedOptions;
6!
526
        this.value = this.getSubmitValue(this.selectedOptions);
6✔
527
        this.addSelectedState(this.selectedOptions);
6✔
528
        this.buildDisplayLabel();
6✔
529
    }
5✔
530

531
    private addSelectedState(selectOptions: ThyCascaderOption[]) {
532
        if (this.isMultiple && this.thyIsOnlySelectLeaf) {
1✔
533
            selectOptions.forEach(opt => {
1✔
534
                if (opt.isLeaf) {
1!
535
                    opt.selected = true;
1✔
536
                    set(opt, 'selected', true);
1✔
537
                }
538
            });
539
        }
×
540
    }
541

542
    setDisabledState(isDisabled: boolean): void {
1✔
543
        this.disabled = isDisabled;
544
    }
6✔
545

546
    public positionChange(position: ConnectedOverlayPositionChange): void {
6✔
547
        const newValue = position.connectionPair.originY === 'bottom' ? 'bottom' : 'top';
4✔
548
        if (this.dropDownPosition !== newValue) {
4✔
549
            this.dropDownPosition = newValue;
550
            this.cdr.detectChanges();
551
        }
552
    }
1✔
553

1✔
554
    private isLoaded(index: number): boolean {
1✔
555
        return this.columns[index] && this.columns[index].length > 0;
1✔
556
    }
557

1✔
558
    public getOptionLabel(option: ThyCascaderOption): any {
1✔
559
        return option[this.thyLabelProperty || 'label'];
560
    }
1✔
561

1!
562
    public getOptionValue(option: ThyCascaderOption): any {
1✔
563
        return option[this.thyValueProperty || 'value'];
564
    }
1✔
565

566
    public isActivatedOption(option: ThyCascaderOption, index: number): boolean {
567
        if (!this.isMultiple) {
2✔
568
            const activeOpt = this.activatedOptions[index];
2✔
569
            return activeOpt === option;
4✔
570
        } else {
1✔
571
            if (option.isLeaf) {
572
                return option.selected;
573
            } else {
574
                const selectedOpts = this.selectionModel.selected;
575
                const appearIndex = selectedOpts.findIndex(item => {
×
576
                    const selectedItem = helpers.get(item, `thyRawValue.value.${index}`);
577
                    return helpers.shallowEqual(selectedItem, option);
578
                });
8✔
579
                return appearIndex >= 0;
8!
580
            }
8✔
581
        }
8✔
582
    }
8✔
583

8✔
584
    public isSelectedOption(option: ThyCascaderOption, index: number): boolean {
1✔
585
        if (this.thyIsOnlySelectLeaf) {
586
            if (option.isLeaf) {
8✔
587
                return option.selected;
8✔
588
            }
589
        } else {
590
            const selectedOpts = this.selectionModel.selected;
591
            const appearIndex = selectedOpts.findIndex(item => {
592
                if (item.thyRawValue.value.length - 1 === index) {
8✔
593
                    const selectedItem = helpers.get(item, `thyRawValue.value.${index}`);
9✔
594
                    return helpers.shallowEqual(selectedItem, option);
8✔
595
                } else {
596
                    return false;
597
                }
1!
598
            });
1✔
599
            return appearIndex >= 0;
1✔
600
        }
601
    }
1✔
602

1✔
603
    public attached(): void {
1✔
604
        this.cdr.detectChanges();
1✔
605
        this.cdkConnectedOverlay.positionChange.pipe(take(1), takeUntil(this.destroy$)).subscribe(() => {
1✔
606
            this.scrollActiveElementIntoView();
1✔
607
        });
1✔
608
    }
609

610
    private scrollActiveElementIntoView() {
1✔
611
        if (!isEmpty(this.selectedOptions)) {
1✔
612
            const activeOptions = this.cascaderOptions
1✔
613
                .filter(item => item.nativeElement.classList.contains('thy-cascader-menu-item-active'))
614
                // for multiple mode
615
                .slice(-this.cascaderOptionContainers.length);
5✔
616

4✔
617
            this.cascaderOptionContainers.forEach((item, index) => {
4✔
618
                if (index <= activeOptions.length - 1) {
3✔
619
                    ScrollToService.scrollToElement(activeOptions[index].nativeElement, item.nativeElement);
3!
620
                    this.cdr.detectChanges();
4✔
621
                }
3✔
622
            });
623
        }
3✔
624
    }
1✔
625

626
    private findOption(option: any, index: number): ThyCascaderOption {
627
        const options: ThyCascaderOption[] = this.columns[index];
1✔
628
        if (options) {
1✔
629
            const value = typeof option === 'object' ? this.getOptionValue(option) : option;
1!
630
            return options.find(o => value === this.getOptionValue(o));
1✔
631
        }
632
        return null;
633
    }
634

635
    private buildDisplayLabel(): void {
1!
636
        const selectedOptions = [...this.selectedOptions];
637
        const labels: string[] = selectedOptions.map(o => this.getOptionLabel(o));
638
        if (labels.length === 0) {
639
            return;
71!
640
        }
71✔
641
        let labelRenderContext;
71✔
642
        let labelRenderText;
14✔
643
        if (this.isLabelRenderTemplate) {
644
            labelRenderContext = { labels, selectedOptions };
645
        } else {
646
            labelRenderText = defaultDisplayRender.call(this, labels, selectedOptions);
647
            this.labelRenderText = labelRenderText;
108✔
648
        }
108!
649
        if (this.labelRenderText || this.isLabelRenderTemplate) {
110✔
650
            const selectedData: SelectOptionBase = {
651
                thyRawValue: {
108✔
652
                    value: selectedOptions,
653
                    labelText: labelRenderText,
654
                    labelRenderContext: labelRenderContext
46✔
655
                },
46✔
656
                thyValue: labels,
46✔
657
                thyLabelText: labelRenderText
46✔
658
            };
46✔
659
            this.selectionModel.select(selectedData);
46✔
660
        }
46✔
661
    }
46✔
662

46✔
663
    public isMenuVisible(): boolean {
46✔
664
        return this.menuVisible;
46✔
665
    }
46✔
666

46✔
667
    public setMenuVisible(menuVisible: boolean): void {
46✔
668
        if (this.menuVisible !== menuVisible) {
46✔
669
            this.menuVisible = menuVisible;
46✔
670

46✔
671
            this.initActivatedOptions();
46✔
672
            this.setClassMap();
46✔
673
            this.setMenuClass();
46✔
674
            if (this.menuVisible) {
46✔
675
                this.triggerRect = this.trigger.nativeElement.getBoundingClientRect();
46✔
676
            }
46✔
677
            this.thyExpandStatusChange.emit(menuVisible);
46✔
678
        }
46✔
679
    }
46✔
680

46✔
681
    private initActivatedOptions() {
46✔
682
        if (isEmpty(this.selectedOptions) || !this.menuVisible) {
46✔
683
            return;
46✔
684
        }
46✔
685
        this.activatedOptions = [...this.selectedOptions];
46✔
686
        this.activatedOptions.forEach((item, index) => {
46✔
687
            if (!isEmpty(item.children) && !item.isLeaf) {
46✔
688
                this.columns[index + 1] = item.children;
46✔
689
            }
46✔
690
        });
46✔
691
    }
46✔
692

46✔
693
    public get menuCls(): any {
46✔
694
        return this._menuCls;
46✔
695
    }
696

697
    private setMenuClass(): void {
698
        this._menuCls = {
699
            [`${this.prefixCls}-menus`]: true,
700
            [`${this.prefixCls}-menus-hidden`]: !this.menuVisible,
701
            [`${this.thyMenuClassName}`]: this.thyMenuClassName,
46✔
702
            [`w-100`]: this.columns.length === 0
46✔
703
        };
46✔
704
    }
705

706
    public get menuColumnCls(): any {
670!
707
        return this._menuColumnCls;
708
    }
709

7✔
710
    private setMenuColumnClass(): void {
1✔
711
        this._menuColumnCls = {
712
            [`${this.prefixCls}-menu`]: true,
7✔
713
            [`${this.thyColumnClassName}`]: this.thyColumnClassName
714
        };
715
    }
46✔
716

47✔
717
    public get labelCls(): any {
718
        return this._labelCls;
6✔
719
    }
720

6✔
721
    private setLabelClass(): void {
6✔
722
        this._labelCls = {
723
            [`${this.prefixCls}-picker-label`]: true,
724
            [`${this.prefixCls}-show-search`]: false,
18✔
725
            [`${this.prefixCls}-focused`]: false
38✔
726
        };
54✔
727
    }
54✔
728

54✔
729
    private setClassMap(): void {
54✔
730
        const classMap = {
54✔
731
            [`${this.prefixCls}`]: true,
54✔
732
            [`${this.prefixCls}-picker`]: true,
54✔
733
            [`${this.prefixCls}-${this.thySize}`]: true,
734
            [`${this.prefixCls}-picker-disabled`]: this.disabled,
735
            [`${this.prefixCls}-picker-open`]: this.menuVisible
736
        };
737
        this.hostRenderer.updateClassByMap(classMap);
738
    }
739

740
    private isClickTriggerAction(): boolean {
54✔
741
        if (typeof this.thyTriggerAction === 'string') {
32✔
742
            return this.thyTriggerAction === 'click';
743
        }
744
        return this.thyTriggerAction.indexOf('click') !== -1;
22✔
745
    }
746

747
    private isHoverTriggerAction(): boolean {
748
        if (typeof this.thyTriggerAction === 'string') {
749
            return this.thyTriggerAction === 'hover';
750
        }
751
        return this.thyTriggerAction.indexOf('hover') !== -1;
752
    }
753

754
    private isHoverExpandTriggerAction(): boolean {
755
        if (typeof this.thyExpandTriggerAction === 'string') {
756
            return this.thyExpandTriggerAction === 'hover';
6✔
757
        }
6✔
758
        return this.thyExpandTriggerAction.indexOf('hover') !== -1;
26✔
759
    }
4✔
760

761
    @HostListener('click', ['$event'])
762
    public toggleClick($event: Event) {
763
        if (this.disabled) {
764
            return;
6✔
765
        }
6✔
766
        if (this.isClickTriggerAction()) {
767
            this.setMenuVisible(!this.menuVisible);
768
        }
8✔
769
    }
8✔
770

8✔
771
    @HostListener('mouseover', ['$event'])
8✔
772
    public toggleHover($event: Event) {
8✔
773
        if (this.disabled) {
774
            return;
775
        }
1✔
776
        if (this.isHoverTriggerAction()) {
1!
777
            this.setMenuVisible(!this.menuVisible);
×
778
        }
×
779
    }
780

×
781
    public clickOption(option: ThyCascaderOption, index: number, event: Event | boolean): void {
782
        if (option && option.disabled && !this.isMultiple) {
1!
783
            return;
×
784
        }
×
785
        const isSelect = event instanceof Event ? (!this.isMultiple && option.isLeaf ? true : false) : true;
×
786
        this.setActiveOption(option, index, isSelect);
×
787
    }
788

×
789
    public mouseoverOption(option: ThyCascaderOption, index: number, event: Event): void {
790
        if (event) {
×
791
            event.preventDefault();
×
792
        }
×
793

×
794
        if (option && option.disabled && !this.isMultiple) {
795
            return;
796
        }
797

1✔
798
        if (!this.isHoverExpandTriggerAction() && !(option && option.disabled && this.isMultiple)) {
3✔
799
            return;
800
        }
1✔
801
        this.setActiveOption(option, index, false);
802
    }
803

804
    public mouseleaveMenu(event: Event) {
46✔
805
        if (event) {
46✔
806
            event.preventDefault();
807
        }
1✔
808
        if (!this.isHoverTriggerAction()) {
809
            return;
810
        }
811
        this.setMenuVisible(!this.menuVisible);
812
    }
1✔
813

814
    onBlur(event?: FocusEvent) {
815
        // Tab 聚焦后自动聚焦到 input 输入框,此分支下直接返回,无需触发 onTouchedFn
816
        if (elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-cascader-menus', 'thy-cascader'])) {
817
            return;
818
        }
819
        this.onTouchedFn();
820
    }
821

822
    onFocus(event?: FocusEvent) {
823
        if (!elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-cascader-menus', 'thy-cascader'])) {
824
            const inputElement: HTMLInputElement = this.elementRef.nativeElement.querySelector('input');
825
            inputElement.focus();
826
        }
827
    }
828

829
    public closeMenu(): void {
830
        if (this.menuVisible) {
831
            this.setMenuVisible(false);
832
            this.onTouchedFn();
833
            this.isShowSearchPanel = false;
834
            this.searchResultList = [];
835
        }
836
    }
837

838
    public setActiveOption(option: ThyCascaderOption, index: number, select: boolean, loadChildren: boolean = true): void {
839
        this.activatedOptions[index] = option;
840
        for (let i = index - 1; i >= 0; i--) {
841
            const originOption = this.activatedOptions[i + 1]?.parent;
842
            if (!this.activatedOptions[i] || originOption?._id !== this.activatedOptions[i]._id) {
843
                this.activatedOptions[i] = originOption ?? this.activatedOptions[i];
844
            }
845
        }
846
        if (index < this.activatedOptions.length - 1) {
847
            this.activatedOptions = this.activatedOptions.slice(0, index + 1);
848
        }
849
        if (isArray(option.children) && option.children.length) {
850
            option.isLeaf = false;
1✔
851
            option.children.forEach(child => (child.parent = option));
852
            this.setColumnData(option.children, index + 1);
853
        } else if (!option.isLeaf && loadChildren) {
854
            this.loadChildren(option, index);
1✔
855
        } else {
856
            if (index < this.columns.length - 1) {
857
                this.columns = this.columns.slice(0, index + 1);
858
            }
1✔
859
        }
860
        if (select) {
861
            this.selectOption(option, index);
862
        }
863
    }
1✔
864

865
    private selectOption(option: ThyCascaderOption, index: number): void {
866
        this.thySelect.emit({ option, index });
867
        const isOptionCanSelect = this.thyChangeOnSelect && !this.isMultiple;
1✔
868
        if (option.isLeaf || !this.thyIsOnlySelectLeaf || isOptionCanSelect || this.shouldPerformSelection(option, index)) {
869
            this.selectedOptions = this.activatedOptions;
870
            this.updatePrevSelectedOptions(option, false, index);
871
            if (option.selected) {
1✔
872
                this.buildDisplayLabel();
873
            } else {
874
                const selectedItems = this.selectionModel.selected;
875
                const currentItem = selectedItems.find(item => {
1✔
876
                    if (item.thyRawValue.value.length - 1 === index) {
877
                        const selectedItem = helpers.get(item, `thyRawValue.value.${index}`);
878
                        return helpers.shallowEqual(selectedItem, option);
879
                    } else {
880
                        return false;
881
                    }
882
                });
46✔
883
                this.selectionModel.deselect(currentItem);
884
            }
885
            this.valueChange();
886
        }
887
        if ((option.isLeaf || !this.thyIsOnlySelectLeaf) && !this.thyMultiple) {
888
            this.setMenuVisible(false);
889
            this.onTouchedFn();
890
        }
891
    }
892

893
    public removeSelectedItem(event: { item: SelectOptionBase; $eventOrigin: Event }) {
894
        const selectedItems = this.selectionModel.selected;
895
        event.$eventOrigin.stopPropagation();
896
        const currentItem = selectedItems.find(item => {
897
            return helpers.shallowEqual(item.thyValue, event.item.thyValue);
898
        });
899
        this.deselectOption(currentItem);
900
        this.selectionModel.deselect(currentItem);
901
        // update selectedOptions
902
        const updatedSelectedItems = this.selectionModel.selected;
903
        if (isArray(updatedSelectedItems) && updatedSelectedItems.length) {
904
            this.selectedOptions = updatedSelectedItems[updatedSelectedItems.length - 1].thyRawValue.value;
905
        }
906
        this.valueChange();
907
    }
908

909
    private deselectOption(option: SelectOptionBase) {
910
        const value: ThyCascaderOption[] = option.thyRawValue.value;
911
        value.forEach(item => {
912
            if (item.isLeaf && item.selected) {
913
                set(item, 'selected', false);
914
            }
915
        });
916
    }
917

918
    private shouldPerformSelection(option: ThyCascaderOption, level: number): boolean {
919
        return typeof this.thyChangeOn === 'function' ? this.thyChangeOn(option, level) === true : false;
920
    }
921

922
    private valueChange(): void {
923
        const value = this.getValues();
924
        if (!arrayEquals(this.value, value)) {
925
            this.defaultValue = null;
926
            this.value = value;
927
            this.onChangeFn(value);
928
            if (this.selectionModel.isEmpty()) {
929
                this.thyClear.emit();
930
            }
931
            this.thySelectionChange.emit(this.selectedOptions);
932
            this.thyChange.emit(value);
933
        }
934
    }
935

936
    private getValues() {
937
        let selectedItems: any[];
938
        const selected = this.selectionModel.selected;
939
        selectedItems = selected.map(item => this.getSubmitValue(item.thyRawValue.value));
940
        return this.isMultiple ? selectedItems : selectedItems[0] ?? selectedItems;
941
    }
942

943
    public clearSelection($event: Event): void {
944
        if ($event) {
945
            $event.stopPropagation();
946
            $event.preventDefault();
947
        }
948
        this.labelRenderText = '';
949
        this.labelRenderContext = {};
950
        this.selectedOptions = [];
951
        this.activatedOptions = [];
952
        this.deselectAllSelected();
953
        this.setMenuVisible(false);
954
        this.valueChange();
955
    }
956

957
    private deselectAllSelected() {
958
        const selectedOptions = this.selectionModel.selected;
959
        selectedOptions.forEach(item => this.deselectOption(item));
960
        this.selectionModel.clear();
961
    }
962

963
    private loadChildren(option: ThyCascaderOption, index: number, success?: () => void, failure?: () => void): void {
964
        if (this.thyLoadData) {
965
            this.isLoading = true;
966
            this.thyLoadData(option, index).then(
967
                () => {
968
                    option.loading = this.isLoading = false;
969
                    if (option.children) {
970
                        option.children.forEach(child => (child.parent = index < 0 ? undefined : option));
971
                        this.setColumnData(option.children, index + 1);
972
                    }
973
                    if (success) {
974
                        success();
975
                    }
976
                },
977
                () => {
978
                    option.loading = this.isLoading = false;
979
                    option.isLeaf = true;
980
                    if (failure) {
981
                        failure();
982
                    }
983
                }
984
            );
985
        } else {
986
            this.setColumnData(option.children || [], index + 1);
987
        }
988
    }
989

990
    private setColumnData(options: ThyCascaderOption[], index: number): void {
991
        if (!arrayEquals(this.columns[index], options)) {
992
            this.columns[index] = options;
993
            if (index < this.columns.length - 1) {
994
                this.columns = this.columns.slice(0, index + 1);
995
            }
996
        }
997
    }
998

999
    private getSubmitValue(originOptions: ThyCascaderOption[]): any[] {
1000
        const values: any[] = [];
1001
        (originOptions || []).forEach(option => {
1002
            values.push(this.getOptionValue(option));
1003
        });
1004
        return values;
1005
    }
1006

1007
    constructor(private cdr: ChangeDetectorRef, private viewPortRuler: ViewportRuler, public elementRef: ElementRef) {
1008
        super();
1009
    }
1010

1011
    public trackByFn(index: number, item: ThyCascaderOption) {
1012
        return item?.value || item?._id || index;
1013
    }
1014

1015
    public searchFilter(searchText: string) {
1016
        if (!searchText && !this.isSelectingSearchState) {
1017
            this.resetSearch();
1018
        }
1019
        this.searchText$.next(searchText);
1020
    }
1021

1022
    private initSearch() {
1023
        this.searchText$
1024
            .pipe(
1025
                takeUntil(this.destroy$),
1026
                debounceTime(200),
1027
                distinctUntilChanged(),
1028
                filter(text => text !== '')
1029
            )
1030
            .subscribe(searchText => {
1031
                this.resetSearch();
1032

1033
                // local search
1034
                this.searchInLocal(searchText);
1035
                this.isShowSearchPanel = true;
1036
            });
1037
    }
1038

1039
    private forEachColumns(
1040
        currentLabel?: string[],
1041
        currentValue: Id[] = [],
1042
        currentRowValue: ThyCascaderOption[] = [],
1043
        list = this.columns[0]
1044
    ) {
1045
        list.forEach(item => {
1046
            const curOptionLabel = this.getOptionLabel(item);
1047
            const curOptionValue = this.getOptionValue(item);
1048
            const label: string[] = currentLabel ? [...currentLabel, curOptionLabel] : [curOptionLabel];
1049
            const valueList: Id[] = [...currentValue, curOptionValue];
1050
            const rowValueList: ThyCascaderOption[] = [...currentRowValue, item];
1051
            const isSelected = this.isSelectedOption(item, valueList.length - 1);
1052

1053
            this.flattenOptions.push({
1054
                labelList: label,
1055
                valueList,
1056
                selected: isSelected,
1057
                thyRowValue: rowValueList,
1058
                isLeaf: item.isLeaf,
1059
                disabled: item.disabled
1060
            });
1061
            if (item.children && item.children.length) {
1062
                this.forEachColumns(label, valueList, rowValueList, item.children);
1063
            } else {
1064
                this.leafNodes.push({
1065
                    labelList: label,
1066
                    valueList,
1067
                    selected: isSelected,
1068
                    thyRowValue: rowValueList,
1069
                    isLeaf: item.isLeaf,
1070
                    disabled: item.disabled
1071
                });
1072
            }
1073
        });
1074
    }
1075

1076
    private setSearchResultList(listOfOption: ThyCascaderSearchOption[], searchText: string) {
1077
        this.searchResultList = [];
1078
        listOfOption.forEach(item => {
1079
            if (!item.disabled && item.isLeaf && item.labelList.join().toLowerCase().indexOf(searchText.toLowerCase()) !== -1) {
1080
                this.searchResultList.push(item);
1081
            }
1082
        });
1083
    }
1084

1085
    private searchInLocal(searchText: string): void {
1086
        this.forEachColumns();
1087

1088
        this.setSearchResultList(this.thyIsOnlySelectLeaf ? this.leafNodes : this.flattenOptions, searchText);
1089
    }
1090

1091
    private resetSearch() {
1092
        this.isShowSearchPanel = false;
1093
        this.searchResultList = [];
1094
        this.leafNodes = [];
1095
        this.flattenOptions = [];
1096
        this.scrollActiveElementIntoView();
1097
    }
1098

1099
    public selectSearchResult(selectOptionData: ThyCascaderSearchOption): void {
1100
        const { thyRowValue: selectedOptions } = selectOptionData;
1101
        if (selectOptionData.selected) {
1102
            if (!this.isMultiple) {
1103
                this.closeMenu();
1104
            }
1105
            return;
1106
        }
1107
        if (this.isMultiple) {
1108
            this.isSelectingSearchState = true;
1109
            selectOptionData.selected = true;
1110
            selectedOptions.forEach((item: ThyCascaderOption, index: number) => {
1111
                this.setActiveOption(item, index, index === selectedOptions.length - 1);
1112
            });
1113
            const originSearchResultList = this.searchResultList;
1114
            // 保持搜索选项
1115
            setTimeout(() => {
1116
                this.isShowSearchPanel = true;
1117
                this.searchResultList = originSearchResultList;
1118
                this.isSelectingSearchState = false;
1119
            });
1120
        } else {
1121
            selectedOptions.forEach((item: ThyCascaderOption, index: number) => {
1122
                this.setActiveOption(item, index, index === selectedOptions.length - 1);
1123
            });
1124

1125
            this.resetSearch();
1126
        }
1127
    }
1128

1129
    ngOnDestroy() {
1130
        this.destroy$.next();
1131
        this.destroy$.complete();
1132
    }
1133
}
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