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

atinc / ngx-tethys / d9ae709b-3c27-4b69-b125-b8b80b54f90b

pending completion
d9ae709b-3c27-4b69-b125-b8b80b54f90b

Pull #2757

circleci

mengshuicmq
fix: fix code review
Pull Request #2757: feat(color-picker): color-picker support disabled (#INFR-8645)

98 of 6315 branches covered (1.55%)

Branch coverage included in aggregate %.

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

2392 of 13661 relevant lines covered (17.51%)

83.12 hits per line

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

1.81
/src/cascader/cascader.component.ts
1
import {
2
    AbstractControlValueAccessor,
3
    Constructor,
4
    EXPANDED_DROPDOWN_POSITIONS,
5
    InputBoolean,
6
    InputNumber,
7
    mixinDisabled,
8
    mixinTabIndex,
9
    ScrollToService,
10
    ThyCanDisable,
11
    ThyHasTabIndex
12
} from 'ngx-tethys/core';
13
import { SelectControlSize, SelectOptionBase } from 'ngx-tethys/shared';
14
import { coerceBooleanProperty, elementMatchClosest, helpers, isArray, isEmpty, set } from 'ngx-tethys/util';
15
import { BehaviorSubject, Subject } from 'rxjs';
16
import { debounceTime, distinctUntilChanged, filter, take, takeUntil } from 'rxjs/operators';
17

18
import { SelectionModel } from '@angular/cdk/collections';
19
import {
20
    CdkConnectedOverlay,
21
    CdkOverlayOrigin,
22
    ConnectedOverlayPositionChange,
×
23
    ConnectionPositionPair,
×
24
    ViewportRuler
25
} from '@angular/cdk/overlay';
×
26
import {
×
27
    ChangeDetectorRef,
28
    Component,
29
    ElementRef,
×
30
    EventEmitter,
31
    forwardRef,
×
32
    HostListener,
33
    Input,
34
    OnDestroy,
×
35
    OnInit,
×
36
    Output,
37
    QueryList,
×
38
    TemplateRef,
×
39
    ViewChild,
×
40
    ViewChildren
×
41
} from '@angular/core';
42
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
43
import { useHostRenderer } from '@tethys/cdk/dom';
×
44
import { ThySelectControlComponent } from 'ngx-tethys/shared';
45

1✔
46
import { NgClass, NgFor, NgIf, NgStyle, NgTemplateOutlet } from '@angular/common';
1✔
47
import { ThyEmptyComponent } from 'ngx-tethys/empty';
48
import { ThyIconComponent } from 'ngx-tethys/icon';
49
import { Id } from 'ngx-tethys/types';
50
import { ThyCascaderOptionComponent } from './cascader-li.component';
51
import { ThyCascaderSearchOptionComponent } from './cascader-search-option.component';
1✔
52
import { ThyCascaderExpandTrigger, ThyCascaderOption, ThyCascaderSearchOption, ThyCascaderTriggerType } from './types';
53

×
54
function toArray<T>(value: T | T[]): T[] {
×
55
    let ret: T[];
×
56
    if (value == null) {
57
        ret = [];
58
    } else if (!Array.isArray(value)) {
59
        ret = [value];
×
60
    } else {
×
61
        ret = value;
62
    }
63
    return ret;
×
64
}
65

66
function arrayEquals<T>(array1: T[], array2: T[]): boolean {
×
67
    if (!array1 || !array2 || array1.length !== array2.length) {
×
68
        return false;
69
    }
70

×
71
    const len = array1.length;
72
    for (let i = 0; i < len; i++) {
73
        if (array1[i] !== array2[i]) {
×
74
            return false;
×
75
        }
76
    }
77
    return true;
×
78
}
79

80
const defaultDisplayRender = (label: any) => label.join(' / ');
×
81

82
const _MixinBase: Constructor<ThyHasTabIndex> & Constructor<ThyCanDisable> & typeof AbstractControlValueAccessor = mixinTabIndex(
83
    mixinDisabled(AbstractControlValueAccessor)
×
84
);
85

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

×
134
    /**
×
135
     * 选项的显示值的属性名
×
136
     */
137
    @Input() thyLabelProperty = 'label';
138

×
139
    /**
×
140
     * 描述输入字段预期值的简短的提示信息
×
141
     */
×
142
    @Input() thyPlaceholder = '请选择';
×
143

144
    /**
×
145
     * 控制大小(4种)
×
146
     * @type 'sm' | 'md' | 'lg' | ''
147
     */
148
    @Input() thySize: SelectControlSize = '';
×
149

×
150
    /**
151
     * 数据项
152
     * @type ThyCascaderOption[]
×
153
     * @default []
×
154
     */
155
    @Input()
156
    set thyOptions(options: ThyCascaderOption[] | null) {
157
        this.columns = options && options.length ? [options] : [];
×
158
        if (this.defaultValue && this.columns.length) {
×
159
            this.initOptions(0);
×
160
        }
×
161
    }
162

163
    /**
×
164
     * 点击父级菜单选项时,可通过该函数判断是否允许值的变化
×
165
     */
166
    @Input() thyChangeOn: (option: ThyCascaderOption, level: number) => boolean;
167

×
168
    /**
×
169
     * 点击项时,表单是否动态展示数据项
170
     * @type boolean
×
171
     */
×
172
    @Input() @InputBoolean() thyChangeOnSelect = false;
×
173

×
174
    /**
175
     * 显示输入框
176
     * @type boolean
×
177
     */
×
178
    @Input() @InputBoolean() thyShowInput = true;
×
179

180
    /**
×
181
     * 用户自定义模板
×
182
     * @type TemplateRef
183
     */
184
    @Input()
185
    set thyLabelRender(value: TemplateRef<any>) {
×
186
        this.labelRenderTpl = value;
×
187
        this.isLabelRenderTemplate = value instanceof TemplateRef;
188
    }
×
189

×
190
    get thyLabelRender(): TemplateRef<any> {
×
191
        return this.labelRenderTpl;
×
192
    }
193

194
    /**
×
195
     * 用于动态加载选项
×
196
     */
×
197
    @Input() thyLoadData: (node: ThyCascaderOption, index?: number) => PromiseLike<any>;
198

199
    /**
200
     * 控制触发状态, 支持 `click` | `hover`
×
201
     * @type ThyCascaderTriggerType | ThyCascaderTriggerType[]
×
202
     */
×
203
    @Input() thyTriggerAction: ThyCascaderTriggerType | ThyCascaderTriggerType[] = ['click'];
×
204

×
205
    /**
206
     * 鼠标经过下方列表项时,是否自动展开列表,支持 `click` | `hover`
207
     * @type ThyCascaderExpandTrigger | ThyCascaderExpandTrigger[]
×
208
     */
×
209
    @Input() thyExpandTriggerAction: ThyCascaderExpandTrigger | ThyCascaderExpandTrigger[] = ['click'];
×
210

211
    /**
212
     * 自定义浮层样式
×
213
     */
214
    @Input() thyMenuStyle: { [key: string]: string };
215

216
    /**
×
217
     * 自定义浮层类名
×
218
     * @type string
×
219
     */
×
220
    @Input()
221
    set thyMenuClassName(value: string) {
222
        this.menuClassName = value;
×
223
        this.setMenuClass();
×
224
    }
×
225

×
226
    get thyMenuClassName(): string {
×
227
        return this.menuClassName;
228
    }
229

230
    /**
231
     * 自定义浮层列类名
232
     * @type string
×
233
     */
234
    @Input()
235
    set thyColumnClassName(value: string) {
×
236
        this.columnClassName = value;
×
237
        this.setMenuClass();
×
238
    }
×
239

240
    get thyColumnClassName(): string {
241
        return this.columnClassName;
242
    }
×
243

244
    /**
245
     * 是否只读
×
246
     * @default false
247
     */
248
    @Input()
×
249
    // eslint-disable-next-line prettier/prettier
250
    override get thyDisabled(): boolean {
251
        return this.disabled;
×
252
    }
×
253

×
254
    override set thyDisabled(value: boolean) {
255
        this.disabled = coerceBooleanProperty(value);
256
    }
×
257

×
258
    disabled = false;
259

260
    /**
×
261
     * 空状态下的展示文字
×
262
     * @default 暂无可选项
×
263
     */
×
264
    @Input()
265
    set thyEmptyStateText(value: string) {
×
266
        this.emptyStateText = value;
267
    }
268

269
    /**
270
     * 是否多选
×
271
     * @type boolean
×
272
     * @default false
×
273
     */
274
    @Input()
275
    @InputBoolean()
276
    set thyMultiple(value: boolean) {
×
277
        this.isMultiple = value;
×
278
        this.initSelectionModel();
×
279
    }
280

281
    get thyMultiple(): boolean {
×
282
        return this.isMultiple;
×
283
    }
×
284

×
285
    /**
286
     * 设置多选时最大显示的标签数量,0 表示不限制
287
     * @type number
288
     */
289
    @Input() @InputNumber() thyMaxTagCount = 0;
290

×
291
    /**
×
292
     * @private 当多选时是否只能选择叶子项, 暂无实现
×
293
     */
×
294
    @Input()
295
    @InputBoolean()
×
296
    thyIsOnlySelectLeaf = true;
297

298
    /**
×
299
     * 是否支持搜索
×
300
     * @default false
×
301
     */
×
302
    @Input() @InputBoolean() thyShowSearch: boolean = false;
303

304
    /**
305
     * 值发生变化时触发,返回选择项的值
×
306
     * @type EventEmitter<any[]>
×
307
     */
308
    @Output() thyChange = new EventEmitter<any[]>();
309

×
310
    /**
×
311
     * 值发生变化时触发,返回选择项列表
312
     * @type EventEmitter<ThyCascaderOption[]>
×
313
     */
×
314
    @Output() thySelectionChange = new EventEmitter<ThyCascaderOption[]>();
315

316
    /**
317
     * 选择选项时触发
318
     */
319
    @Output() thySelect = new EventEmitter<{
320
        option: ThyCascaderOption;
321
        index: number;
322
    }>();
×
323

324
    /**
325
     * @private 暂无实现
326
     */
×
327
    @Output() thyDeselect = new EventEmitter<{
328
        option: ThyCascaderOption;
329
        index: number;
×
330
    }>();
×
331

×
332
    /**
×
333
     * 清空选项时触发
×
334
     */
×
335
    @Output() thyClear = new EventEmitter<void>();
×
336

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

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

×
341
    @ViewChild(CdkConnectedOverlay, { static: true }) cdkConnectedOverlay: CdkConnectedOverlay;
×
342

343
    @ViewChild('trigger', { read: ElementRef, static: true }) trigger: ElementRef<any>;
×
344

×
345
    @ViewChild('input') input: ElementRef;
×
346

×
347
    @ViewChild('menu') menu: ElementRef;
348

349
    public dropDownPosition = 'bottom';
350
    public menuVisible = false;
351
    public isLoading = false;
×
352
    public showSearch = false;
353
    public labelRenderText: string;
354
    public labelRenderContext: any = {};
×
355
    public isLabelRenderTemplate = false;
356
    public triggerRect: DOMRect;
357
    public columns: ThyCascaderOption[][] = [];
358
    public emptyStateText = '暂无可选项';
359

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

374
    private value: any[];
×
375

376
    private selectedOptions: ThyCascaderOption[] = [];
377

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

380
    get selected(): SelectOptionBase | SelectOptionBase[] {
381
        this.cdkConnectedOverlay?.overlayRef?.updatePosition();
×
382
        return this.thyMultiple ? this.selectionModel.selected : this.selectionModel.selected[0];
383
    }
384

385
    private isMultiple = false;
386

387
    private prevSelectedOptions: ThyCascaderOption[] = [];
388

×
389
    public menuMinWidth = 122;
390

391
    private searchText$ = new BehaviorSubject('');
×
392

×
393
    public searchResultList: ThyCascaderSearchOption[] = [];
394

×
395
    public isShowSearchPanel: boolean = false;
396

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

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

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

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

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

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

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

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

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

523
    afterWriteValue(): void {
×
524
        this.selectedOptions = this.activatedOptions;
×
525
        this.value = this.getSubmitValue(this.selectedOptions);
×
526
        this.addSelectedState(this.selectedOptions);
527
        this.buildDisplayLabel();
528
    }
529

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

541
    setDisabledState(isDisabled: boolean): void {
×
542
        this.disabled = isDisabled;
543
    }
544

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

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

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

×
561
    public getOptionValue(option: ThyCascaderOption): any {
×
562
        return option[this.thyValueProperty || 'value'];
563
    }
×
564

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

×
583
    public attached(): void {
×
584
        this.cdr.detectChanges();
×
585
        this.cdkConnectedOverlay.positionChange.pipe(take(1), takeUntil(this.destroy$)).subscribe(() => {
586
            this.scrollActiveElementIntoView();
587
        });
×
588
    }
×
589

×
590
    private scrollActiveElementIntoView() {
591
        if (!isEmpty(this.selectedOptions)) {
592
            const activeOptions = this.cascaderOptions
×
593
                .filter(item => item.nativeElement.classList.contains('thy-cascader-menu-item-active'))
×
594
                // for multiple mode
×
595
                .slice(-this.cascaderOptionContainers.length);
×
596

×
597
            this.cascaderOptionContainers.forEach((item, index) => {
×
598
                if (index <= activeOptions.length - 1) {
×
599
                    ScrollToService.scrollToElement(activeOptions[index].nativeElement, item.nativeElement);
600
                    this.cdr.detectChanges();
×
601
                }
×
602
            });
603
        }
604
    }
×
605

×
606
    private findOption(option: any, index: number): ThyCascaderOption {
×
607
        const options: ThyCascaderOption[] = this.columns[index];
×
608
        if (options) {
609
            const value = typeof option === 'object' ? this.getOptionValue(option) : option;
610
            return options.find(o => value === this.getOptionValue(o));
611
        }
612
        return null;
×
613
    }
614

615
    private buildDisplayLabel(): void {
616
        const selectedOptions = [...this.selectedOptions];
×
617
        const labels: string[] = selectedOptions.map(o => this.getOptionLabel(o));
×
618
        if (labels.length === 0) {
×
619
            return;
×
620
        }
621
        let labelRenderContext;
622
        let labelRenderText;
623
        if (this.isLabelRenderTemplate) {
624
            labelRenderContext = { labels, selectedOptions };
×
625
        } else {
×
626
            labelRenderText = defaultDisplayRender.call(this, labels, selectedOptions);
×
627
            this.labelRenderText = labelRenderText;
628
        }
×
629
        if (this.labelRenderText || this.isLabelRenderTemplate) {
630
            const selectedData: SelectOptionBase = {
631
                thyRawValue: {
×
632
                    value: selectedOptions,
×
633
                    labelText: labelRenderText,
×
634
                    labelRenderContext: labelRenderContext
×
635
                },
×
636
                thyValue: labels,
×
637
                thyLabelText: labelRenderText
×
638
            };
×
639
            this.selectionModel.select(selectedData);
×
640
        }
×
641
    }
×
642

×
643
    public isMenuVisible(): boolean {
×
644
        return this.menuVisible;
×
645
    }
×
646

×
647
    public setMenuVisible(menuVisible: boolean): void {
×
648
        if (this.menuVisible !== menuVisible) {
×
649
            this.menuVisible = menuVisible;
×
650

×
651
            this.initActivatedOptions();
×
652
            this.setClassMap();
×
653
            this.setMenuClass();
×
654
            if (this.menuVisible) {
×
655
                this.triggerRect = this.trigger.nativeElement.getBoundingClientRect();
×
656
            }
×
657
        }
×
658
    }
×
659

×
660
    private initActivatedOptions() {
×
661
        if (isEmpty(this.selectedOptions) || !this.menuVisible) {
×
662
            return;
×
663
        }
×
664
        this.activatedOptions = [...this.selectedOptions];
×
665
        this.activatedOptions.forEach((item, index) => {
×
666
            if (!isEmpty(item.children) && !item.isLeaf) {
×
667
                this.columns[index + 1] = item.children;
×
668
            }
×
669
        });
×
670
    }
×
671

672
    public get menuCls(): any {
673
        return this._menuCls;
674
    }
675

676
    private setMenuClass(): void {
677
        this._menuCls = {
×
678
            [`${this.prefixCls}-menus`]: true,
679
            [`${this.prefixCls}-menus-hidden`]: !this.menuVisible,
680
            [`${this.thyMenuClassName}`]: this.thyMenuClassName,
×
681
            [`w-100`]: this.columns.length === 0
682
        };
683
    }
×
684

×
685
    public get menuColumnCls(): any {
686
        return this._menuColumnCls;
×
687
    }
688

689
    private setMenuColumnClass(): void {
×
690
        this._menuColumnCls = {
×
691
            [`${this.prefixCls}-menu`]: true,
692
            [`${this.thyColumnClassName}`]: this.thyColumnClassName
×
693
        };
694
    }
×
695

×
696
    public get labelCls(): any {
697
        return this._labelCls;
698
    }
×
699

×
700
    private setLabelClass(): void {
×
701
        this._labelCls = {
×
702
            [`${this.prefixCls}-picker-label`]: true,
×
703
            [`${this.prefixCls}-show-search`]: false,
×
704
            [`${this.prefixCls}-focused`]: false
×
705
        };
×
706
    }
×
707

708
    private setClassMap(): void {
709
        const classMap = {
710
            [`${this.prefixCls}`]: true,
×
711
            [`${this.prefixCls}-picker`]: true,
×
712
            [`${this.prefixCls}-${this.thySize}`]: true,
713
            [`${this.prefixCls}-picker-disabled`]: this.disabled,
714
            [`${this.prefixCls}-picker-open`]: this.menuVisible
715
        };
716
        this.hostRenderer.updateClassByMap(classMap);
717
    }
718

719
    private isClickTriggerAction(): boolean {
720
        if (typeof this.thyTriggerAction === 'string') {
721
            return this.thyTriggerAction === 'click';
722
        }
×
723
        return this.thyTriggerAction.indexOf('click') !== -1;
×
724
    }
×
725

726
    private isHoverTriggerAction(): boolean {
727
        if (typeof this.thyTriggerAction === 'string') {
×
728
            return this.thyTriggerAction === 'hover';
×
729
        }
×
730
        return this.thyTriggerAction.indexOf('hover') !== -1;
×
731
    }
732

×
733
    private isHoverExpandTriggerAction(): boolean {
734
        if (typeof this.thyExpandTriggerAction === 'string') {
×
735
            return this.thyExpandTriggerAction === 'hover';
×
736
        }
×
737
        return this.thyExpandTriggerAction.indexOf('hover') !== -1;
×
738
    }
×
739

740
    @HostListener('click', ['$event'])
×
741
    public toggleClick($event: Event) {
742
        if (this.disabled) {
×
743
            return;
×
744
        }
×
745
        if (this.isClickTriggerAction()) {
×
746
            this.setMenuVisible(!this.menuVisible);
747
        }
748
    }
749

×
750
    @HostListener('mouseover', ['$event'])
×
751
    public toggleHover($event: Event) {
752
        if (this.disabled) {
×
753
            return;
754
        }
755
        if (this.isHoverTriggerAction()) {
756
            this.setMenuVisible(!this.menuVisible);
×
757
        }
×
758
    }
759

1✔
760
    public clickOption(option: ThyCascaderOption, index: number, event: Event): void {
761
        if (option.isLeaf && event instanceof Event && this.isMultiple) {
762
            return;
763
        }
764
        if (option && option.disabled && !this.isMultiple) {
1✔
765
            return;
766
        }
767
        this.setActiveOption(option, index, true);
768
        this.valueChange();
769
    }
770

771
    public mouseoverOption(option: ThyCascaderOption, index: number, event: Event): void {
772
        if (event) {
773
            event.preventDefault();
774
        }
775

776
        if (option && option.disabled && !this.isMultiple) {
777
            return;
778
        }
779

780
        if (!this.isHoverExpandTriggerAction() && !(option && option.disabled && this.isMultiple)) {
781
            return;
782
        }
783
        this.setActiveOption(option, index, false);
784
    }
785

786
    public mouseleaveMenu(event: Event) {
787
        if (event) {
788
            event.preventDefault();
789
        }
790
        if (!this.isHoverTriggerAction()) {
791
            return;
792
        }
793
        this.setMenuVisible(!this.menuVisible);
794
    }
795

796
    onBlur(event?: FocusEvent) {
797
        // Tab 聚焦后自动聚焦到 input 输入框,此分支下直接返回,无需触发 onTouchedFn
798
        if (elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-cascader-menus', 'thy-cascader'])) {
799
            return;
800
        }
801
        this.onTouchedFn();
1✔
802
    }
803

804
    onFocus(event?: FocusEvent) {
805
        if (!elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-cascader-menus', 'thy-cascader'])) {
1✔
806
            const inputElement: HTMLInputElement = this.elementRef.nativeElement.querySelector('input');
807
            inputElement.focus();
808
        }
809
    }
1✔
810

811
    public closeMenu(): void {
812
        if (this.menuVisible) {
813
            this.setMenuVisible(false);
814
            this.onTouchedFn();
1✔
815
            this.isShowSearchPanel = false;
816
            this.searchResultList = [];
817
        }
818
    }
1✔
819

820
    public setActiveOption(option: ThyCascaderOption, index: number, select: boolean, loadChildren: boolean = true): void {
821
        this.activatedOptions[index] = option;
822
        for (let i = index - 1; i >= 0; i--) {
1✔
823
            const originOption = this.activatedOptions[i + 1]?.parent;
824
            if (!this.activatedOptions[i] || originOption?._id !== this.activatedOptions[i]._id) {
825
                this.activatedOptions[i] = originOption ?? this.activatedOptions[i];
826
            }
1✔
827
        }
828
        if (index < this.activatedOptions.length - 1) {
829
            this.activatedOptions = this.activatedOptions.slice(0, index + 1);
830
        }
831
        if (isArray(option.children) && option.children.length) {
832
            option.isLeaf = false;
833
            option.children.forEach(child => (child.parent = option));
×
834
            this.setColumnData(option.children, index + 1);
835
        } else if (!option.isLeaf && loadChildren) {
836
            this.loadChildren(option, index);
837
        } else {
838
            if (index < this.columns.length - 1) {
839
                this.columns = this.columns.slice(0, index + 1);
840
            }
841
        }
842
        if (select) {
843
            this.selectOption(option, index);
844
        }
845
    }
846

847
    private selectOption(option: ThyCascaderOption, index: number): void {
848
        this.thySelect.emit({ option, index });
849
        const isOptionCanSelect = this.thyChangeOnSelect && !this.isMultiple;
850
        if (option.isLeaf || isOptionCanSelect || this.shouldPerformSelection(option, index)) {
851
            this.selectedOptions = this.activatedOptions;
852
            this.updatePrevSelectedOptions(option);
853
            if (option.selected) {
854
                this.buildDisplayLabel();
855
            } else {
856
                const selectedItems = this.selectionModel.selected;
857
                const currentItem = selectedItems.find(item => {
858
                    const selectedItem = helpers.get(item, `thyRawValue.value.${index}`);
859
                    return helpers.shallowEqual(selectedItem, option);
860
                });
861
                this.selectionModel.deselect(currentItem);
862
            }
863
            this.valueChange();
864
        }
865
        if (option.isLeaf && !this.thyMultiple) {
866
            this.setMenuVisible(false);
867
            this.onTouchedFn();
868
        }
869
    }
870

871
    public removeSelectedItem(event: { item: SelectOptionBase; $eventOrigin: Event }) {
872
        const selectedItems = this.selectionModel.selected;
873
        event.$eventOrigin.stopPropagation();
874
        const currentItem = selectedItems.find(item => {
875
            return helpers.shallowEqual(item.thyValue, event.item.thyValue);
876
        });
877
        this.deselectOption(currentItem);
878
        this.selectionModel.deselect(currentItem);
879
        // update selectedOptions
880
        const updatedSelectedItems = this.selectionModel.selected;
881
        if (isArray(updatedSelectedItems) && updatedSelectedItems.length) {
882
            this.selectedOptions = updatedSelectedItems[updatedSelectedItems.length - 1].thyRawValue.value;
883
        }
884
        this.valueChange();
885
    }
886

887
    private deselectOption(option: SelectOptionBase) {
888
        const value: ThyCascaderOption[] = option.thyRawValue.value;
889
        value.forEach(item => {
890
            if (item.isLeaf && item.selected) {
891
                set(item, 'selected', false);
892
            }
893
        });
894
    }
895

896
    private shouldPerformSelection(option: ThyCascaderOption, level: number): boolean {
897
        return typeof this.thyChangeOn === 'function' ? this.thyChangeOn(option, level) === true : false;
898
    }
899

900
    private valueChange(): void {
901
        const value = this.getValues();
902
        if (!arrayEquals(this.value, value)) {
903
            this.defaultValue = null;
904
            this.value = value;
905
            this.onChangeFn(value);
906
            if (this.selectionModel.isEmpty()) {
907
                this.thyClear.emit();
908
            }
909
            this.thySelectionChange.emit(this.selectedOptions);
910
            this.thyChange.emit(value);
911
        }
912
    }
913

914
    private getValues() {
915
        let selectedItems: any[];
916
        const selected = this.selectionModel.selected;
917
        selectedItems = selected.map(item => this.getSubmitValue(item.thyRawValue.value));
918
        return this.isMultiple ? selectedItems : selectedItems[0] ?? selectedItems;
919
    }
920

921
    public clearSelection($event: Event): void {
922
        if ($event) {
923
            $event.stopPropagation();
924
            $event.preventDefault();
925
        }
926
        this.labelRenderText = '';
927
        this.labelRenderContext = {};
928
        this.selectedOptions = [];
929
        this.activatedOptions = [];
930
        this.deselectAllSelected();
931
        this.setMenuVisible(false);
932
        this.valueChange();
933
    }
934

935
    private deselectAllSelected() {
936
        const selectedOptions = this.selectionModel.selected;
937
        selectedOptions.forEach(item => this.deselectOption(item));
938
        this.selectionModel.clear();
939
    }
940

941
    private loadChildren(option: ThyCascaderOption, index: number, success?: () => void, failure?: () => void): void {
942
        if (this.thyLoadData) {
943
            this.isLoading = true;
944
            this.thyLoadData(option, index).then(
945
                () => {
946
                    option.loading = this.isLoading = false;
947
                    if (option.children) {
948
                        option.children.forEach(child => (child.parent = index < 0 ? undefined : option));
949
                        this.setColumnData(option.children, index + 1);
950
                    }
951
                    if (success) {
952
                        success();
953
                    }
954
                },
955
                () => {
956
                    option.loading = this.isLoading = false;
957
                    option.isLeaf = true;
958
                    if (failure) {
959
                        failure();
960
                    }
961
                }
962
            );
963
        } else {
964
            this.setColumnData(option.children || [], index + 1);
965
        }
966
    }
967

968
    private setColumnData(options: ThyCascaderOption[], index: number): void {
969
        if (!arrayEquals(this.columns[index], options)) {
970
            this.columns[index] = options;
971
            if (index < this.columns.length - 1) {
972
                this.columns = this.columns.slice(0, index + 1);
973
            }
974
        }
975
    }
976

977
    private getSubmitValue(originOptions: ThyCascaderOption[]): any[] {
978
        const values: any[] = [];
979
        (originOptions || []).forEach(option => {
980
            values.push(this.getOptionValue(option));
981
        });
982
        return values;
983
    }
984

985
    constructor(private cdr: ChangeDetectorRef, private viewPortRuler: ViewportRuler, public elementRef: ElementRef) {
986
        super();
987
    }
988

989
    public trackByFn(index: number, item: ThyCascaderOption) {
990
        return item?.value || item?._id || index;
991
    }
992

993
    public searchFilter(searchText: string) {
994
        if (!searchText && !this.isSelectingSearchState) {
995
            this.resetSearch();
996
        }
997
        this.searchText$.next(searchText);
998
    }
999

1000
    private initSearch() {
1001
        this.searchText$
1002
            .pipe(
1003
                takeUntil(this.destroy$),
1004
                debounceTime(200),
1005
                distinctUntilChanged(),
1006
                filter(text => text !== '')
1007
            )
1008
            .subscribe(searchText => {
1009
                this.resetSearch();
1010

1011
                // local search
1012
                this.searchInLocal(searchText);
1013
                this.isShowSearchPanel = true;
1014
            });
1015
    }
1016

1017
    private searchInLocal(
1018
        searchText: string,
1019
        currentLabel?: string[],
1020
        currentValue: Id[] = [],
1021
        currentRowValue: ThyCascaderOption[] = [],
1022
        list = this.columns[0]
1023
    ): void {
1024
        list.forEach(item => {
1025
            const curOptionLabel = this.getOptionLabel(item);
1026
            const curOptionValue = this.getOptionValue(item);
1027
            const label: string[] = currentLabel ? [...currentLabel, curOptionLabel] : [curOptionLabel];
1028
            const valueList: Id[] = [...currentValue, curOptionValue];
1029
            const rowValueList: ThyCascaderOption[] = [...currentRowValue, item];
1030

1031
            if (item.children && item.children.length) {
1032
                this.searchInLocal(searchText, label, valueList, rowValueList, item.children);
1033
            } else {
1034
                // 目前只支持搜索根节点
1035
                if (!item.disabled && item.isLeaf && curOptionLabel.toLowerCase().indexOf(searchText.toLowerCase()) !== -1) {
1036
                    this.searchResultList.push({
1037
                        labelList: label,
1038
                        valueList,
1039
                        selected: item.selected,
1040
                        thyRowValue: rowValueList
1041
                    });
1042
                }
1043
            }
1044
        });
1045
    }
1046

1047
    private resetSearch() {
1048
        this.isShowSearchPanel = false;
1049
        this.searchResultList = [];
1050
        this.scrollActiveElementIntoView();
1051
    }
1052

1053
    public selectSearchResult(selectOptionData: ThyCascaderSearchOption): void {
1054
        const { thyRowValue: selectedOptions } = selectOptionData;
1055
        if (selectOptionData.selected) {
1056
            if (!this.isMultiple) {
1057
                this.closeMenu();
1058
            }
1059
            return;
1060
        }
1061
        if (this.isMultiple) {
1062
            this.isSelectingSearchState = true;
1063
            selectOptionData.selected = true;
1064
            selectedOptions.forEach((item: ThyCascaderOption, index: number) => {
1065
                this.setActiveOption(item, index, item.isLeaf);
1066
            });
1067
            const originSearchResultList = this.searchResultList;
1068
            // 保持搜索选项
1069
            setTimeout(() => {
1070
                this.isShowSearchPanel = true;
1071
                this.searchResultList = originSearchResultList;
1072
                this.isSelectingSearchState = false;
1073
            });
1074
        } else {
1075
            selectedOptions.forEach((item: ThyCascaderOption, index: number) => {
1076
                this.setActiveOption(item, index, item.isLeaf);
1077
            });
1078

1079
            this.resetSearch();
1080
        }
1081
    }
1082

1083
    ngOnDestroy() {
1084
        this.destroy$.next();
1085
        this.destroy$.complete();
1086
    }
1087
}
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