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

atinc / ngx-tethys / e42eb54e-db75-4ac6-9d54-d98ab6d94e44

04 Sep 2023 08:42AM UTC coverage: 90.2%. Remained the same
e42eb54e-db75-4ac6-9d54-d98ab6d94e44

Pull #2829

circleci

cmm-va
fix: reset form code
Pull Request #2829: fix: add tabIndex

5163 of 6383 branches covered (0.0%)

Branch coverage included in aggregate %.

76 of 76 new or added lines in 24 files covered. (100.0%)

13015 of 13770 relevant lines covered (94.52%)

972.29 hits per line

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

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

13
import { SelectionModel } from '@angular/cdk/collections';
14
import {
15
    CdkConnectedOverlay,
16
    CdkOverlayOrigin,
17
    ConnectedOverlayPositionChange,
18
    ConnectionPositionPair,
19
    ViewportRuler
20
} from '@angular/cdk/overlay';
21
import {
22
    ChangeDetectorRef,
118✔
23
    Component,
72✔
24
    ElementRef,
25
    EventEmitter,
46✔
26
    forwardRef,
6✔
27
    HostListener,
28
    Input,
29
    OnDestroy,
40✔
30
    OnInit,
31
    Output,
118✔
32
    QueryList,
33
    TemplateRef,
34
    ViewChild,
94✔
35
    ViewChildren
67✔
36
} from '@angular/core';
37
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
27✔
38
import { useHostRenderer } from '@tethys/cdk/dom';
27✔
39
import { ThySelectControlComponent } from 'ngx-tethys/shared';
23✔
40

19✔
41
import { NgClass, NgFor, NgIf, NgStyle, NgTemplateOutlet } from '@angular/common';
42
import { ThyEmptyComponent } from 'ngx-tethys/empty';
43
import { ThyIconComponent } from 'ngx-tethys/icon';
8✔
44
import { Id } from 'ngx-tethys/types';
45
import { ThyCascaderOptionComponent } from './cascader-li.component';
33✔
46
import { ThyCascaderSearchOptionComponent } from './cascader-search-option.component';
47
import { ThyCascaderExpandTrigger, ThyCascaderOption, ThyCascaderSearchOption, ThyCascaderTriggerType } from './types';
48

49
function toArray<T>(value: T | T[]): T[] {
50
    let ret: T[];
1✔
51
    if (value == null) {
52
        ret = [];
51✔
53
    } else if (!Array.isArray(value)) {
51✔
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

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

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

88✔
77
/**
78
 * 级联选择菜单
79
 * @name thy-cascader
360✔
80
 */
81
@Component({
82
    selector: 'thy-cascader,[thy-cascader]',
41✔
83
    templateUrl: './cascader.component.html',
84
    providers: [
85
        {
29✔
86
            provide: NG_VALUE_ACCESSOR,
87
            useExisting: forwardRef(() => ThyCascaderComponent),
88
            multi: true
11✔
89
        }
11✔
90
    ],
91
    host: {
92
        '[attr.tabindex]': `tabIndex`,
1,762✔
93
        '(focus)': 'onFocus($event)',
94
        '(blur)': 'onBlur($event)'
95
    },
428✔
96
    styles: [
428✔
97
        `
98
            .thy-cascader-menus {
99
                position: relative;
44✔
100
            }
44✔
101
        `
44✔
102
    ],
44✔
103
    standalone: true,
44✔
104
    imports: [
44✔
105
        CdkOverlayOrigin,
44✔
106
        NgIf,
33✔
107
        ThySelectControlComponent,
108
        NgClass,
44✔
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 {
11!
120
    /**
×
121
     * 选项的实际值的属性名
122
     */
123
    @Input() thyValueProperty = 'value';
11✔
124

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

249
    disabled = false;
250

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

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

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

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

26✔
282
    /**
23✔
283
     * @private 当多选时是否只能选择叶子项, 暂无实现
23✔
284
     */
285
    @Input()
286
    @InputBoolean()
287
    thyIsOnlySelectLeaf = true;
288

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

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

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

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

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

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

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

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

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

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

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

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

31✔
340
    public dropDownPosition = 'bottom';
20✔
341
    public menuVisible = false;
342
    public isLoading = false;
11✔
343
    public showSearch = false;
11✔
344
    public labelRenderText: string;
25✔
345
    public labelRenderContext: any = {};
15✔
346
    public isLabelRenderTemplate = false;
347
    public triggerRect: DOMRect;
348
    public columns: ThyCascaderOption[][] = [];
349
    public emptyStateText = '暂无可选项';
350

215✔
351
    public selectionModel: SelectionModel<SelectOptionBase>;
352
    private prefixCls = 'thy-cascader';
353
    private menuClassName: string;
131✔
354
    private columnClassName: string;
355
    private _menuColumnCls: any;
356
    private defaultValue: any[];
357
    private readonly destroy$ = new Subject<void>();
358
    private _menuCls: { [name: string]: any };
359
    private _labelCls: { [name: string]: any };
360
    private labelRenderTpl: TemplateRef<any>;
361
    private hostRenderer = useHostRenderer();
374✔
362
    private cascaderPosition: ConnectionPositionPair[];
363
    positions: ConnectionPositionPair[];
364

44✔
365
    private value: any[];
366

367
    private selectedOptions: ThyCascaderOption[] = [];
368

369
    private activatedOptions: ThyCascaderOption[] = [];
370

248✔
371
    get selected(): SelectOptionBase | SelectOptionBase[] {
372
        this.cdkConnectedOverlay?.overlayRef?.updatePosition();
373
        return this.thyMultiple ? this.selectionModel.selected : this.selectionModel.selected[0];
44✔
374
    }
375

376
    private isMultiple = false;
377

378
    private prevSelectedOptions: ThyCascaderOption[] = [];
379

380
    public menuMinWidth = 122;
75✔
381

382
    private searchText$ = new BehaviorSubject('');
383

384
    public searchResultList: ThyCascaderSearchOption[] = [];
385

386
    public isShowSearchPanel: boolean = false;
387

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

396
    ngOnInit(): void {
4!
397
        this.setClassMap();
4✔
398
        this.setMenuClass();
399
        this.setMenuColumnClass();
×
400
        this.setLabelClass();
401
        this.initPosition();
402
        this.initSearch();
2!
403
        if (!this.selectionModel) {
2✔
404
            this.selectionModel = new SelectionModel<SelectOptionBase>(this.thyMultiple);
405
        }
×
406
        this.viewPortRuler
407
            .change(100)
408
            .pipe(takeUntil(this.destroy$))
27✔
409
            .subscribe(() => {
2✔
410
                if (this.menuVisible) {
411
                    this.triggerRect = this.trigger.nativeElement.getBoundingClientRect();
25!
412
                    this.cdr.markForCheck();
25✔
413
                }
414
            });
415
    }
416

2!
417
    private initSelectionModel() {
×
418
        if (this.selectionModel) {
419
            this.selectionModel.clear();
2!
420
        } else {
2✔
421
            this.selectionModel = new SelectionModel(this.isMultiple);
422
        }
423
    }
424

20✔
425
    private initPosition() {
4✔
426
        this.cascaderPosition = EXPANDED_DROPDOWN_POSITIONS.map(item => {
427
            return { ...item };
16!
428
        });
×
429
        this.cascaderPosition[0].offsetY = 4; // 左下
430
        this.cascaderPosition[1].offsetY = 4; // 右下
16✔
431
        this.cascaderPosition[2].offsetY = -4; // 右下
16✔
432
        this.cascaderPosition[3].offsetY = -4; // 右下
433
        this.positions = this.cascaderPosition;
434
    }
2!
435

2✔
436
    private initOptions(index: number) {
437
        const vs = this.defaultValue;
2!
438
        const load = () => {
×
439
            this.activateOnInit(index, vs[index]);
440
            if (index < vs.length - 1) {
2!
441
                this.initOptions(index + 1);
1✔
442
            }
443
            if (index === vs.length - 1) {
1✔
444
                this.afterWriteValue();
445
            }
446
        };
2!
447

2✔
448
        if (this.isLoaded(index) || !this.thyLoadData) {
449
            load();
2✔
450
        } else {
1✔
451
            const node = this.activatedOptions[index - 1] || {};
452
            this.loadChildren(node, index - 1, load, this.afterWriteValue.bind(this));
1✔
453
        }
454
    }
455

456
    private activateOnInit(index: number, value: any): void {
1!
457
        let option = this.findOption(value, index);
×
458
        if (!option) {
459
            option =
1✔
460
                typeof value === 'object'
461
                    ? value
462
                    : {
1!
463
                          [`${this.thyValueProperty || 'value'}`]: value,
1✔
464
                          [`${this.thyLabelProperty || 'label'}`]: value
1✔
465
                      };
466
        }
467
        this.updatePrevSelectedOptions(option, true);
468
        this.setActiveOption(option, index, false, false);
5✔
469
    }
1✔
470

1✔
471
    private updatePrevSelectedOptions(option: ThyCascaderOption, isActivateInit = false) {
1✔
472
        if (isActivateInit) {
1✔
473
            set(option, 'selected', true);
474
            this.prevSelectedOptions.push(option);
475
        } else {
20✔
476
            const isSelected = !option.selected;
111✔
477
            while (this.prevSelectedOptions.length && !this.thyMultiple) {
111✔
478
                set(this.prevSelectedOptions.pop(), 'selected', false);
94✔
479
            }
94!
480
            set(option, 'selected', isSelected);
×
481
            this.prevSelectedOptions.push(option);
482
        }
483
    }
111✔
484

14✔
485
    writeValue(value: any): void {
486
        if (!this.selectionModel) {
111✔
487
            this.initSelectionModel();
67✔
488
        }
67✔
489
        if (!this.isMultiple) {
67✔
490
            const vs = (this.defaultValue = toArray(value));
491
            if (vs.length) {
44✔
492
                this.initOptions(0);
3✔
493
            } else {
494
                this.value = vs;
495
                this.activatedOptions = [];
41!
496
                this.afterWriteValue();
×
497
            }
498
        } else {
499
            const values = toArray(value);
111✔
500
            values.forEach(item => {
17✔
501
                const vs = (this.defaultValue = toArray(item));
502
                if (vs.length) {
503
                    this.initOptions(0);
504
                } else {
17✔
505
                    this.value = vs;
17✔
506
                    this.activatedOptions = [];
17✔
507
                    this.afterWriteValue();
5✔
508
                }
5✔
509
            });
5✔
510
            this.cdr.detectChanges();
4✔
511
        }
512
    }
513

1✔
514
    afterWriteValue(): void {
1✔
515
        this.selectedOptions = this.activatedOptions;
1✔
516
        this.value = this.getSubmitValue(this.selectedOptions);
1✔
517
        this.addSelectedState(this.selectedOptions);
518
        this.buildDisplayLabel();
1✔
519
    }
520

5✔
521
    private addSelectedState(selectOptions: ThyCascaderOption[]) {
522
        if (this.isMultiple && this.thyIsOnlySelectLeaf) {
17✔
523
            selectOptions.forEach(opt => {
2✔
524
                if (opt.isLeaf) {
2✔
525
                    opt.selected = true;
526
                    set(opt, 'selected', true);
527
                }
528
            });
1✔
529
        }
1✔
530
    }
1✔
531

1✔
532
    setDisabledState(isDisabled: boolean): void {
533
        this.disabled = isDisabled;
1✔
534
    }
1✔
535

536
    public positionChange(position: ConnectedOverlayPositionChange): void {
1✔
537
        const newValue = position.connectionPair.originY === 'bottom' ? 'bottom' : 'top';
1!
538
        if (this.dropDownPosition !== newValue) {
1✔
539
            this.dropDownPosition = newValue;
540
            this.cdr.detectChanges();
1✔
541
        }
542
    }
543

2✔
544
    private isLoaded(index: number): boolean {
2✔
545
        return this.columns[index] && this.columns[index].length > 0;
4✔
546
    }
1✔
547

548
    public getOptionLabel(option: ThyCascaderOption): any {
549
        return option[this.thyLabelProperty || 'label'];
550
    }
551

12!
552
    public getOptionValue(option: ThyCascaderOption): any {
553
        return option[this.thyValueProperty || 'value'];
554
    }
23✔
555

23✔
556
    public isActivatedOption(option: ThyCascaderOption, index: number): boolean {
15✔
557
        if (!this.isMultiple) {
15✔
558
            const activeOpt = this.activatedOptions[index];
15✔
559
            return activeOpt === option;
15✔
560
        } else {
1✔
561
            if (option.isLeaf) {
562
                return option.selected;
15✔
563
            } else {
15✔
564
                const selectedOpts = this.selectionModel.selected;
565
                const appearIndex = selectedOpts.findIndex(item => {
566
                    const selectedItem = helpers.get(item, `thyRawValue.value.${index}`);
567
                    return helpers.shallowEqual(selectedItem, option);
568
                });
23✔
569
                return appearIndex >= 0;
26✔
570
            }
23✔
571
        }
572
    }
573

1!
574
    public attached(): void {
1✔
575
        this.cdr.detectChanges();
1✔
576
        this.cdkConnectedOverlay.positionChange.pipe(take(1), takeUntil(this.destroy$)).subscribe(() => {
577
            this.scrollActiveElementIntoView();
1✔
578
        });
1✔
579
    }
1✔
580

1✔
581
    private scrollActiveElementIntoView() {
1✔
582
        if (!isEmpty(this.selectedOptions)) {
1✔
583
            const activeOptions = this.cascaderOptions
1✔
584
                .filter(item => item.nativeElement.classList.contains('thy-cascader-menu-item-active'))
585
                // for multiple mode
586
                .slice(-this.cascaderOptionContainers.length);
1✔
587

1✔
588
            this.cascaderOptionContainers.forEach((item, index) => {
1✔
589
                if (index <= activeOptions.length - 1) {
590
                    ScrollToService.scrollToElement(activeOptions[index].nativeElement, item.nativeElement);
591
                    this.cdr.detectChanges();
5✔
592
                }
4✔
593
            });
4✔
594
        }
3✔
595
    }
3!
596

3✔
597
    private findOption(option: any, index: number): ThyCascaderOption {
3✔
598
        const options: ThyCascaderOption[] = this.columns[index];
599
        if (options) {
3✔
600
            const value = typeof option === 'object' ? this.getOptionValue(option) : option;
1✔
601
            return options.find(o => value === this.getOptionValue(o));
602
        }
603
        return null;
1✔
604
    }
1✔
605

1!
606
    private buildDisplayLabel(): void {
1✔
607
        const selectedOptions = [...this.selectedOptions];
608
        const labels: string[] = selectedOptions.map(o => this.getOptionLabel(o));
609
        if (labels.length === 0) {
610
            return;
611
        }
1!
612
        let labelRenderContext;
613
        let labelRenderText;
614
        if (this.isLabelRenderTemplate) {
615
            labelRenderContext = { labels, selectedOptions };
71!
616
        } else {
71✔
617
            labelRenderText = defaultDisplayRender.call(this, labels, selectedOptions);
71✔
618
            this.labelRenderText = labelRenderText;
14✔
619
        }
620
        if (this.labelRenderText || this.isLabelRenderTemplate) {
621
            const selectedData: SelectOptionBase = {
622
                thyRawValue: {
623
                    value: selectedOptions,
121✔
624
                    labelText: labelRenderText,
121!
625
                    labelRenderContext: labelRenderContext
161✔
626
                },
627
                thyValue: labels,
121✔
628
                thyLabelText: labelRenderText
629
            };
630
            this.selectionModel.select(selectedData);
44✔
631
        }
44✔
632
    }
44✔
633

44✔
634
    public isMenuVisible(): boolean {
44✔
635
        return this.menuVisible;
44✔
636
    }
44✔
637

44✔
638
    public setMenuVisible(menuVisible: boolean): void {
44✔
639
        if (this.menuVisible !== menuVisible) {
44✔
640
            this.menuVisible = menuVisible;
44✔
641

44✔
642
            this.initActivatedOptions();
44✔
643
            this.setClassMap();
44✔
644
            this.setMenuClass();
44✔
645
            if (this.menuVisible) {
44✔
646
                this.triggerRect = this.trigger.nativeElement.getBoundingClientRect();
44✔
647
            }
44✔
648
        }
44✔
649
    }
44✔
650

44✔
651
    private initActivatedOptions() {
44✔
652
        if (isEmpty(this.selectedOptions) || !this.menuVisible) {
44✔
653
            return;
44✔
654
        }
44✔
655
        this.activatedOptions = [...this.selectedOptions];
44✔
656
        this.activatedOptions.forEach((item, index) => {
44✔
657
            if (!isEmpty(item.children) && !item.isLeaf) {
44✔
658
                this.columns[index + 1] = item.children;
44✔
659
            }
44✔
660
        });
44✔
661
    }
44✔
662

44✔
663
    public get menuCls(): any {
44✔
664
        return this._menuCls;
44✔
665
    }
44✔
666

44✔
667
    private setMenuClass(): void {
44✔
668
        this._menuCls = {
44✔
669
            [`${this.prefixCls}-menus`]: true,
44✔
670
            [`${this.prefixCls}-menus-hidden`]: !this.menuVisible,
671
            [`${this.thyMenuClassName}`]: this.thyMenuClassName,
672
            [`w-100`]: this.columns.length === 0
673
        };
674
    }
675

676
    public get menuColumnCls(): any {
44✔
677
        return this._menuColumnCls;
678
    }
679

591!
680
    private setMenuColumnClass(): void {
681
        this._menuColumnCls = {
682
            [`${this.prefixCls}-menu`]: true,
6✔
683
            [`${this.thyColumnClassName}`]: this.thyColumnClassName
1✔
684
        };
685
    }
6✔
686

687
    public get labelCls(): any {
688
        return this._labelCls;
44✔
689
    }
45✔
690

691
    private setLabelClass(): void {
5✔
692
        this._labelCls = {
693
            [`${this.prefixCls}-picker-label`]: true,
5✔
694
            [`${this.prefixCls}-show-search`]: false,
5✔
695
            [`${this.prefixCls}-focused`]: false
696
        };
697
    }
15✔
698

33✔
699
    private setClassMap(): void {
43✔
700
        const classMap = {
43✔
701
            [`${this.prefixCls}`]: true,
43✔
702
            [`${this.prefixCls}-picker`]: true,
43✔
703
            [`${this.prefixCls}-${this.thySize}`]: true,
43✔
704
            [`${this.prefixCls}-picker-disabled`]: this.disabled,
43✔
705
            [`${this.prefixCls}-picker-open`]: this.menuVisible
28✔
706
        };
707
        this.hostRenderer.updateClassByMap(classMap);
708
    }
709

15✔
710
    private isClickTriggerAction(): boolean {
3✔
711
        if (typeof this.thyTriggerAction === 'string') {
712
            return this.thyTriggerAction === 'click';
713
        }
714
        return this.thyTriggerAction.indexOf('click') !== -1;
715
    }
716

717
    private isHoverTriggerAction(): boolean {
718
        if (typeof this.thyTriggerAction === 'string') {
719
            return this.thyTriggerAction === 'hover';
720
        }
721
        return this.thyTriggerAction.indexOf('hover') !== -1;
7✔
722
    }
7✔
723

7✔
724
    private isHoverExpandTriggerAction(): boolean {
725
        if (typeof this.thyExpandTriggerAction === 'string') {
726
            return this.thyExpandTriggerAction === 'hover';
1✔
727
        }
1!
728
        return this.thyExpandTriggerAction.indexOf('hover') !== -1;
×
729
    }
×
730

731
    @HostListener('click', ['$event'])
×
732
    public toggleClick($event: Event) {
733
        if (this.disabled) {
1!
734
            return;
×
735
        }
×
736
        if (this.isClickTriggerAction()) {
×
737
            this.setMenuVisible(!this.menuVisible);
×
738
        }
739
    }
×
740

741
    @HostListener('mouseover', ['$event'])
×
742
    public toggleHover($event: Event) {
×
743
        if (this.disabled) {
×
744
            return;
×
745
        }
746
        if (this.isHoverTriggerAction()) {
747
            this.setMenuVisible(!this.menuVisible);
748
        }
1✔
749
    }
3✔
750

751
    public clickOption(option: ThyCascaderOption, index: number, event: Event): void {
1✔
752
        if (option.isLeaf && event instanceof Event && this.isMultiple) {
753
            return;
754
        }
755
        if (option && option.disabled && !this.isMultiple) {
44✔
756
            return;
44✔
757
        }
758
        this.setActiveOption(option, index, true);
1✔
759
        this.valueChange();
760
    }
761

762
    public mouseoverOption(option: ThyCascaderOption, index: number, event: Event): void {
763
        if (event) {
1✔
764
            event.preventDefault();
765
        }
766

767
        if (option && option.disabled && !this.isMultiple) {
768
            return;
769
        }
770

771
        if (!this.isHoverExpandTriggerAction() && !(option && option.disabled && this.isMultiple)) {
772
            return;
773
        }
774
        this.setActiveOption(option, index, false);
775
    }
776

777
    public mouseleaveMenu(event: Event) {
778
        if (event) {
779
            event.preventDefault();
780
        }
781
        if (!this.isHoverTriggerAction()) {
782
            return;
783
        }
784
        this.setMenuVisible(!this.menuVisible);
785
    }
786

787
    onBlur(event?: FocusEvent) {
788
        // Tab 聚焦后自动聚焦到 input 输入框,此分支下直接返回,无需触发 onTouchedFn
789
        if (elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-cascader-menus', 'thy-cascader'])) {
790
            return;
791
        }
792
        this.onTouchedFn();
793
    }
794

795
    onFocus(event?: FocusEvent) {
796
        if (!elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-cascader-menus', 'thy-cascader'])) {
797
            const inputElement: HTMLInputElement = this.elementRef.nativeElement.querySelector('input');
798
            inputElement.focus();
799
        }
800
    }
1✔
801

802
    public closeMenu(): void {
803
        if (this.menuVisible) {
804
            this.setMenuVisible(false);
1✔
805
            this.onTouchedFn();
806
            this.isShowSearchPanel = false;
807
            this.searchResultList = [];
808
        }
1✔
809
    }
810

811
    public setActiveOption(option: ThyCascaderOption, index: number, select: boolean, loadChildren: boolean = true): void {
812
        this.activatedOptions[index] = option;
813
        for (let i = index - 1; i >= 0; i--) {
1✔
814
            const originOption = this.activatedOptions[i + 1]?.parent;
815
            if (!this.activatedOptions[i] || originOption?._id !== this.activatedOptions[i]._id) {
816
                this.activatedOptions[i] = originOption ?? this.activatedOptions[i];
817
            }
1✔
818
        }
819
        if (index < this.activatedOptions.length - 1) {
820
            this.activatedOptions = this.activatedOptions.slice(0, index + 1);
821
        }
1✔
822
        if (isArray(option.children) && option.children.length) {
823
            option.isLeaf = false;
824
            option.children.forEach(child => (child.parent = option));
825
            this.setColumnData(option.children, index + 1);
1✔
826
        } else if (!option.isLeaf && loadChildren) {
827
            this.loadChildren(option, index);
828
        } else {
829
            if (index < this.columns.length - 1) {
830
                this.columns = this.columns.slice(0, index + 1);
831
            }
832
        }
44✔
833
        if (select) {
834
            this.selectOption(option, index);
835
        }
836
    }
837

838
    private selectOption(option: ThyCascaderOption, index: number): void {
839
        this.thySelect.emit({ option, index });
840
        const isOptionCanSelect = this.thyChangeOnSelect && !this.isMultiple;
841
        if (option.isLeaf || isOptionCanSelect || this.shouldPerformSelection(option, index)) {
842
            this.selectedOptions = this.activatedOptions;
843
            this.updatePrevSelectedOptions(option);
844
            if (option.selected) {
845
                this.buildDisplayLabel();
846
            } else {
847
                const selectedItems = this.selectionModel.selected;
848
                const currentItem = selectedItems.find(item => {
849
                    const selectedItem = helpers.get(item, `thyRawValue.value.${index}`);
850
                    return helpers.shallowEqual(selectedItem, option);
851
                });
852
                this.selectionModel.deselect(currentItem);
853
            }
854
            this.valueChange();
855
        }
856
        if (option.isLeaf && !this.thyMultiple) {
857
            this.setMenuVisible(false);
858
            this.onTouchedFn();
859
        }
860
    }
861

862
    public removeSelectedItem(event: { item: SelectOptionBase; $eventOrigin: Event }) {
863
        const selectedItems = this.selectionModel.selected;
864
        event.$eventOrigin.stopPropagation();
865
        const currentItem = selectedItems.find(item => {
866
            return helpers.shallowEqual(item.thyValue, event.item.thyValue);
867
        });
868
        this.deselectOption(currentItem);
869
        this.selectionModel.deselect(currentItem);
870
        // update selectedOptions
871
        const updatedSelectedItems = this.selectionModel.selected;
872
        if (isArray(updatedSelectedItems) && updatedSelectedItems.length) {
873
            this.selectedOptions = updatedSelectedItems[updatedSelectedItems.length - 1].thyRawValue.value;
874
        }
875
        this.valueChange();
876
    }
877

878
    private deselectOption(option: SelectOptionBase) {
879
        const value: ThyCascaderOption[] = option.thyRawValue.value;
880
        value.forEach(item => {
881
            if (item.isLeaf && item.selected) {
882
                set(item, 'selected', false);
883
            }
884
        });
885
    }
886

887
    private shouldPerformSelection(option: ThyCascaderOption, level: number): boolean {
888
        return typeof this.thyChangeOn === 'function' ? this.thyChangeOn(option, level) === true : false;
889
    }
890

891
    private valueChange(): void {
892
        const value = this.getValues();
893
        if (!arrayEquals(this.value, value)) {
894
            this.defaultValue = null;
895
            this.value = value;
896
            this.onChangeFn(value);
897
            if (this.selectionModel.isEmpty()) {
898
                this.thyClear.emit();
899
            }
900
            this.thySelectionChange.emit(this.selectedOptions);
901
            this.thyChange.emit(value);
902
        }
903
    }
904

905
    private getValues() {
906
        let selectedItems: any[];
907
        const selected = this.selectionModel.selected;
908
        selectedItems = selected.map(item => this.getSubmitValue(item.thyRawValue.value));
909
        return this.isMultiple ? selectedItems : selectedItems[0] ?? selectedItems;
910
    }
911

912
    public clearSelection($event: Event): void {
913
        if ($event) {
914
            $event.stopPropagation();
915
            $event.preventDefault();
916
        }
917
        this.labelRenderText = '';
918
        this.labelRenderContext = {};
919
        this.selectedOptions = [];
920
        this.activatedOptions = [];
921
        this.deselectAllSelected();
922
        this.setMenuVisible(false);
923
        this.valueChange();
924
    }
925

926
    private deselectAllSelected() {
927
        const selectedOptions = this.selectionModel.selected;
928
        selectedOptions.forEach(item => this.deselectOption(item));
929
        this.selectionModel.clear();
930
    }
931

932
    private loadChildren(option: ThyCascaderOption, index: number, success?: () => void, failure?: () => void): void {
933
        if (this.thyLoadData) {
934
            this.isLoading = true;
935
            this.thyLoadData(option, index).then(
936
                () => {
937
                    option.loading = this.isLoading = false;
938
                    if (option.children) {
939
                        option.children.forEach(child => (child.parent = index < 0 ? undefined : option));
940
                        this.setColumnData(option.children, index + 1);
941
                    }
942
                    if (success) {
943
                        success();
944
                    }
945
                },
946
                () => {
947
                    option.loading = this.isLoading = false;
948
                    option.isLeaf = true;
949
                    if (failure) {
950
                        failure();
951
                    }
952
                }
953
            );
954
        } else {
955
            this.setColumnData(option.children || [], index + 1);
956
        }
957
    }
958

959
    private setColumnData(options: ThyCascaderOption[], index: number): void {
960
        if (!arrayEquals(this.columns[index], options)) {
961
            this.columns[index] = options;
962
            if (index < this.columns.length - 1) {
963
                this.columns = this.columns.slice(0, index + 1);
964
            }
965
        }
966
    }
967

968
    private getSubmitValue(originOptions: ThyCascaderOption[]): any[] {
969
        const values: any[] = [];
970
        (originOptions || []).forEach(option => {
971
            values.push(this.getOptionValue(option));
972
        });
973
        return values;
974
    }
975

976
    constructor(private cdr: ChangeDetectorRef, private viewPortRuler: ViewportRuler, public elementRef: ElementRef) {
977
        super();
978
    }
979

980
    public trackByFn(index: number, item: ThyCascaderOption) {
981
        return item?.value || item?._id || index;
982
    }
983

984
    public searchFilter(searchText: string) {
985
        if (!searchText && !this.isSelectingSearchState) {
986
            this.resetSearch();
987
        }
988
        this.searchText$.next(searchText);
989
    }
990

991
    private initSearch() {
992
        this.searchText$
993
            .pipe(
994
                takeUntil(this.destroy$),
995
                debounceTime(200),
996
                distinctUntilChanged(),
997
                filter(text => text !== '')
998
            )
999
            .subscribe(searchText => {
1000
                this.resetSearch();
1001

1002
                // local search
1003
                this.searchInLocal(searchText);
1004
                this.isShowSearchPanel = true;
1005
            });
1006
    }
1007

1008
    private searchInLocal(
1009
        searchText: string,
1010
        currentLabel?: string[],
1011
        currentValue: Id[] = [],
1012
        currentRowValue: ThyCascaderOption[] = [],
1013
        list = this.columns[0]
1014
    ): void {
1015
        list.forEach(item => {
1016
            const curOptionLabel = this.getOptionLabel(item);
1017
            const curOptionValue = this.getOptionValue(item);
1018
            const label: string[] = currentLabel ? [...currentLabel, curOptionLabel] : [curOptionLabel];
1019
            const valueList: Id[] = [...currentValue, curOptionValue];
1020
            const rowValueList: ThyCascaderOption[] = [...currentRowValue, item];
1021

1022
            if (item.children && item.children.length) {
1023
                this.searchInLocal(searchText, label, valueList, rowValueList, item.children);
1024
            } else {
1025
                // 目前只支持搜索根节点
1026
                if (!item.disabled && item.isLeaf && curOptionLabel.toLowerCase().indexOf(searchText.toLowerCase()) !== -1) {
1027
                    this.searchResultList.push({
1028
                        labelList: label,
1029
                        valueList,
1030
                        selected: item.selected,
1031
                        thyRowValue: rowValueList
1032
                    });
1033
                }
1034
            }
1035
        });
1036
    }
1037

1038
    private resetSearch() {
1039
        this.isShowSearchPanel = false;
1040
        this.searchResultList = [];
1041
        this.scrollActiveElementIntoView();
1042
    }
1043

1044
    public selectSearchResult(selectOptionData: ThyCascaderSearchOption): void {
1045
        const { thyRowValue: selectedOptions } = selectOptionData;
1046
        if (selectOptionData.selected) {
1047
            if (!this.isMultiple) {
1048
                this.closeMenu();
1049
            }
1050
            return;
1051
        }
1052
        if (this.isMultiple) {
1053
            this.isSelectingSearchState = true;
1054
            selectOptionData.selected = true;
1055
            selectedOptions.forEach((item: ThyCascaderOption, index: number) => {
1056
                this.setActiveOption(item, index, item.isLeaf);
1057
            });
1058
            const originSearchResultList = this.searchResultList;
1059
            // 保持搜索选项
1060
            setTimeout(() => {
1061
                this.isShowSearchPanel = true;
1062
                this.searchResultList = originSearchResultList;
1063
                this.isSelectingSearchState = false;
1064
            });
1065
        } else {
1066
            selectedOptions.forEach((item: ThyCascaderOption, index: number) => {
1067
                this.setActiveOption(item, index, item.isLeaf);
1068
            });
1069

1070
            this.resetSearch();
1071
        }
1072
    }
1073

1074
    ngOnDestroy() {
1075
        this.destroy$.next();
1076
        this.destroy$.complete();
1077
    }
1078
}
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

© 2026 Coveralls, Inc