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

atinc / ngx-tethys / cf156675-ee77-4813-934b-5d491b1be73d

11 Dec 2023 02:55AM UTC coverage: 90.452% (+0.09%) from 90.363%
cf156675-ee77-4813-934b-5d491b1be73d

Pull #2894

circleci

smile1016
Merge branch 'zxl/#INFR-10254' of github.com:atinc/ngx-tethys into zxl/#INFR-10254
Pull Request #2894: feat(cascader): add service for cascader #INFR-10254

5348 of 6574 branches covered (0.0%)

Branch coverage included in aggregate %.

299 of 314 new or added lines in 2 files covered. (95.22%)

16 existing lines in 2 files now uncovered.

13352 of 14100 relevant lines covered (94.7%)

972.56 hits per line

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

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

31✔
47
import { ThyCascaderOptionComponent } from './cascader-li.component';
31✔
48
import { ThyCascaderSearchOptionComponent } from './cascader-search-option.component';
49
import { ThyCascaderExpandTrigger, ThyCascaderOption, ThyCascaderSearchOption, ThyCascaderTriggerType } from './types';
50
import { ThyCascaderService } from './cascader.service';
300✔
51

52
/**
53
 * 级联选择菜单
31✔
54
 * @name thy-cascader
31✔
55
 */
56
@Component({
57
    selector: 'thy-cascader,[thy-cascader]',
98✔
58
    templateUrl: './cascader.component.html',
59
    providers: [
60
        {
416✔
61
            provide: NG_VALUE_ACCESSOR,
62
            useExisting: forwardRef(() => ThyCascaderComponent),
63
            multi: true
46✔
64
        },
65
        ThyCascaderService
66
    ],
32✔
67
    host: {
68
        '[attr.tabindex]': `tabIndex`,
69
        '(focus)': 'onFocus($event)',
45✔
70
        '(blur)': 'onBlur($event)'
45✔
71
    },
72
    standalone: true,
73
    imports: [
2,406✔
74
        CdkOverlayOrigin,
75
        NgIf,
76
        ThySelectControlComponent,
502✔
77
        NgClass,
502✔
78
        NgTemplateOutlet,
79
        CdkConnectedOverlay,
80
        NgStyle,
28✔
81
        NgFor,
82
        ThyCascaderOptionComponent,
UNCOV
83
        ThyCascaderSearchOptionComponent,
×
84
        ThyEmptyComponent,
85
        ThyIconComponent
86
    ]
649✔
87
})
88
export class ThyCascaderComponent
89
    extends TabIndexDisabledControlValueAccessorMixin
49✔
90
    implements ControlValueAccessor, OnInit, OnChanges, OnDestroy
49✔
91
{
49✔
92
    /**
49✔
93
     * 选项的实际值的属性名
49✔
94
     */
49✔
95
    @Input() thyValueProperty = 'value';
49✔
96

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

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

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

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

127
    /**
128
     * 点击父级菜单选项时,可通过该函数判断是否允许值的变化
42✔
129
     */
130
    @Input() thyChangeOn: (option: ThyCascaderOption, level: number) => boolean;
131

1✔
132
    /**
1✔
133
     * 点击项时,表单是否动态展示数据项
1✔
134
     * @type boolean
135
     */
136
    @Input() @InputBoolean() thyChangeOnSelect = false;
137

138
    /**
139
     * 显示输入框
140
     * @type boolean
74✔
141
     */
34✔
142
    @Input() @InputBoolean() thyShowInput = true;
143

144
    /**
145
     * 用户自定义模板
49✔
146
     * @type TemplateRef
196✔
147
     */
148
    @Input()
49✔
149
    set thyLabelRender(value: TemplateRef<any>) {
49✔
150
        this.labelRenderTpl = value;
49✔
151
        this.isLabelRenderTemplate = value instanceof TemplateRef;
49✔
152
        this.thyCascaderService.setCascaderOptions({ isLabelRenderTemplate: this.isLabelRenderTemplate });
49✔
153
    }
154

155
    get thyLabelRender(): TemplateRef<any> {
108✔
156
        return this.labelRenderTpl;
108✔
157
    }
30✔
158

159
    /**
160
     * 用于动态加载选项
161
     */
49✔
162
    @Input() set thyLoadData(value: (node: ThyCascaderOption, index?: number) => PromiseLike<any>) {
163
        this.thyCascaderService.setCascaderOptions({ loadData: value });
164
    }
218✔
165

218✔
166
    get thyLoadData() {
1✔
167
        return this.thyCascaderService?.cascaderOptions?.loadData;
1✔
168
    }
169

170
    /**
171
     * 控制触发状态, 支持 `click` | `hover`
1,381✔
172
     * @type ThyCascaderTriggerType | ThyCascaderTriggerType[]
173
     */
174
    @Input() thyTriggerAction: ThyCascaderTriggerType | ThyCascaderTriggerType[] = ['click'];
1,381✔
175

176
    /**
177
     * 鼠标经过下方列表项时,是否自动展开列表,支持 `click` | `hover`
1,381✔
178
     * @type ThyCascaderExpandTrigger | ThyCascaderExpandTrigger[]
179
     */
180
    @Input() thyExpandTriggerAction: ThyCascaderExpandTrigger | ThyCascaderExpandTrigger[] = ['click'];
32✔
181

32✔
182
    /**
32✔
183
     * 自定义浮层样式
184
     */
185
    @Input() thyMenuStyle: { [key: string]: string };
186

40✔
187
    /**
15✔
188
     * 自定义浮层类名
79✔
189
     * @type string
190
     */
191
    @Input()
15✔
192
    set thyMenuClassName(value: string) {
32✔
193
        this.menuClassName = value;
29✔
194
        this.setMenuClass();
29✔
195
    }
196

197
    get thyMenuClassName(): string {
198
        return this.menuClassName;
199
    }
200

40✔
201
    /**
39✔
202
     * 自定义浮层列类名
39✔
203
     * @type string
39✔
204
     */
39✔
205
    @Input()
39✔
206
    set thyColumnClassName(value: string) {
33✔
207
        this.columnClassName = value;
208
        this.setMenuClass();
39✔
209
    }
210

211
    get thyColumnClassName(): string {
212
        return this.columnClassName;
271✔
213
    }
214

215
    /**
150✔
216
     * 是否只读
217
     * @default false
218
     */
219
    @Input()
220
    // eslint-disable-next-line prettier/prettier
221
    override get thyDisabled(): boolean {
222
        return this.disabled;
223
    }
502✔
224

225
    override set thyDisabled(value: boolean) {
226
        this.disabled = coerceBooleanProperty(value);
49✔
227
    }
228

229
    disabled = false;
230

231
    /**
232
     * 空状态下的展示文字
360✔
233
     * @default 暂无可选项
234
     */
235
    @Input()
49✔
236
    set thyEmptyStateText(value: string) {
237
        this.emptyStateText = value;
238
    }
239

240
    /**
241
     * 是否多选
242
     * @type boolean
243
     * @default false
88✔
244
     */
245
    @Input()
246
    @InputBoolean()
247
    set thyMultiple(value: boolean) {
248
        this.isMultiple = value;
249
        this.thyCascaderService.setCascaderOptions({ isMultiple: value });
250
    }
88✔
251

252
    get thyMultiple(): boolean {
253
        return this.isMultiple;
31✔
254
    }
21✔
255

256
    /**
10✔
257
     * 设置多选时最大显示的标签数量,0 表示不限制
258
     * @type number
259
     */
4!
260
    @Input() @InputNumber() thyMaxTagCount = 0;
4✔
261

UNCOV
262
    /**
×
263
     * 是否仅允许选择叶子项
264
     * @default true
265
     */
2!
266
    @Input()
2✔
267
    @InputBoolean()
UNCOV
268
    thyIsOnlySelectLeaf = true;
×
269

270
    /**
271
     * 是否支持搜索
33✔
272
     * @default false
2✔
273
     */
274
    @Input() @InputBoolean() thyShowSearch: boolean = false;
31!
275

31✔
276
    /**
277
     * 多选选中项的展示方式,默认为空,渲染文字模板,传入tag,渲染展示模板,
278
     * @default ''|tag
279
     */
2!
280
    @Input() thyPreset: string = '';
×
281

282
    /**
2!
283
     * 值发生变化时触发,返回选择项的值
2✔
284
     * @type EventEmitter<any[]>
285
     */
286
    @Output() thyChange = new EventEmitter<any[]>();
287

20✔
288
    /**
289
     * 值发生变化时触发,返回选择项列表
290
     * @type EventEmitter<ThyCascaderOption[]>
2!
291
     */
2✔
292
    @Output() thySelectionChange = new EventEmitter<ThyCascaderOption[]>();
293

2!
UNCOV
294
    /**
×
295
     * 选择选项时触发
296
     */
2!
297
    @Output() thySelect = new EventEmitter<{
1✔
298
        option: ThyCascaderOption;
299
        index: number;
1✔
300
    }>();
301

302
    /**
2!
303
     * @private 暂无实现
2✔
304
     */
305
    @Output() thyDeselect = new EventEmitter<{
2✔
306
        option: ThyCascaderOption;
1✔
307
        index: number;
308
    }>();
1✔
309

310
    /**
311
     * 清空选项时触发
312
     */
1!
UNCOV
313
    @Output() thyClear = new EventEmitter<void>();
×
314

315
    /**
1✔
316
     * 下拉选项展开和折叠状态事件
317
     */
318
    @Output() thyExpandStatusChange: EventEmitter<boolean> = new EventEmitter<boolean>();
1!
319

1✔
320
    @ViewChildren('cascaderOptions', { read: ElementRef }) cascaderOptions: QueryList<ElementRef>;
1✔
321

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

324
    @ViewChild(CdkConnectedOverlay, { static: true }) cdkConnectedOverlay: CdkConnectedOverlay;
7✔
325

1✔
326
    @ViewChild('trigger', { read: ElementRef, static: true }) trigger: ElementRef<any>;
1✔
327

1✔
328
    @ViewChild('input') input: ElementRef;
1✔
329

330
    @ViewChild('menu') menu: ElementRef;
331

4✔
332
    public dropDownPosition = 'bottom';
4✔
333

334
    public menuVisible = false;
335

1✔
336
    public isLabelRenderTemplate = false;
1✔
337

338
    public triggerRect: DOMRect;
NEW
339

×
340
    public emptyStateText = '暂无可选项';
341

342
    private prefixCls = 'thy-cascader';
1!
343

1✔
344
    private menuClassName: string;
1✔
345

346
    private columnClassName: string;
1✔
347

1✔
348
    private _menuColumnCls: any;
349

350
    private readonly destroy$ = new Subject<void>();
49✔
351

49✔
352
    private _menuCls: { [name: string]: any };
49✔
353

49✔
354
    private _labelCls: { [name: string]: any };
49✔
355

49✔
356
    private labelRenderTpl: TemplateRef<any>;
49✔
357

49✔
358
    private hostRenderer = useHostRenderer();
49✔
359

49✔
360
    public positions: ConnectionPositionPair[];
49✔
361

49✔
362
    get selected(): SelectOptionBase | SelectOptionBase[] {
49✔
363
        this.cdkConnectedOverlay?.overlayRef?.updatePosition();
49✔
364
        return this.thyMultiple ? this.thyCascaderService.selectionModel.selected : this.thyCascaderService.selectionModel.selected[0];
49✔
365
    }
49✔
366
    private isMultiple = false;
49✔
367

49✔
368
    public menuMinWidth = 122;
49✔
369

49✔
370
    private searchText$ = new BehaviorSubject('');
49✔
371

49✔
372
    public get searchResultList(): ThyCascaderSearchOption[] {
49✔
373
        return this.thyCascaderService.searchResultList;
49✔
374
    }
49✔
375

49✔
376
    public isShowSearchPanel: boolean = false;
49✔
377

49✔
378
    /**
49✔
379
     * 解决搜索&多选的情况下,点击搜索项会导致 panel 闪烁
49✔
380
     * 由于点击后,thySelectedOptions变化,导致 thySelectControl
49✔
381
     * 又会触发 searchFilter 函数,即 resetSearch 会执行
49✔
382
     * 会导致恢复级联状态再变为搜索状态
49✔
383
     */
49✔
384
    private isSelectingSearchState: boolean = false;
49✔
385

49✔
386
    public get isLoading() {
49✔
387
        return this.thyCascaderService?.isLoading;
49✔
388
    }
389

390
    public get columns() {
391
        return this.thyCascaderService.columns;
392
    }
393

394
    ngOnInit(): void {
49✔
395
        this.setClassMap();
49✔
396
        this.setMenuClass();
9✔
397
        this.setMenuColumnClass();
9✔
398
        this.setLabelClass();
9!
399
        this.initPosition();
9✔
400
        this.initSearch();
401
        const options = {
9✔
402
            labelProperty: this.thyLabelProperty,
4✔
403
            valueProperty: this.thyValueProperty,
4✔
404
            isMultiple: this.isMultiple,
405
            isOnlySelectLeaf: this.thyIsOnlySelectLeaf,
406
            isLabelRenderTemplate: this.isLabelRenderTemplate,
407
            loadData: this.thyLoadData
408
        };
931!
409
        this.thyCascaderService.setCascaderOptions(options);
410

411
        this.viewPortRuler
7✔
412
            .change(100)
1✔
413
            .pipe(takeUntil(this.destroy$))
414
            .subscribe(() => {
7✔
415
                if (this.menuVisible) {
416
                    this.triggerRect = this.trigger.nativeElement.getBoundingClientRect();
417
                    this.cdr.markForCheck();
49✔
418
                }
50✔
419
            });
420

6✔
421
        this.thyCascaderService.cascaderValueChange().subscribe(options => {
422
            if (!options.isValueEqual) {
6✔
423
                this.onChangeFn(options.value);
6✔
424
                if (options.isSelectionModelEmpty) {
425
                    this.thyClear.emit();
426
                }
427
                this.thySelectionChange.emit(this.thyCascaderService.selectedOptions);
6✔
428
                this.thyChange.emit(options.value);
429
            }
430
        });
8✔
431
        if (isPlatformBrowser(this.platformId)) {
8✔
432
            this.thyClickDispatcher
8✔
433
                .clicked(0)
434
                .pipe(takeUntil(this.destroy$))
435
                .subscribe(event => {
1✔
436
                    if (
1!
UNCOV
437
                        !this.elementRef.nativeElement.contains(event.target) &&
×
UNCOV
438
                        !this.menu?.nativeElement.contains(event.target as Node) &&
×
439
                        this.menuVisible
UNCOV
440
                    ) {
×
441
                        this.ngZone.run(() => {
442
                            this.closeMenu();
1✔
443
                            this.cdr.markForCheck();
3✔
444
                        });
445
                    }
1!
UNCOV
446
                });
×
UNCOV
447
        }
×
UNCOV
448
    }
×
449

NEW
450
    ngOnChanges(changes: SimpleChanges): void {
×
NEW
451
        if (changes['thyIsOnlySelectLeaf']) {
×
NEW
452
            this.thyCascaderService.setCascaderOptions({ isOnlySelectLeaf: changes['thyIsOnlySelectLeaf'].currentValue });
×
UNCOV
453
        }
×
454
    }
455

456
    private initPosition() {
457
        const cascaderPosition: ConnectionPositionPair[] = EXPANDED_DROPDOWN_POSITIONS.map(item => {
1✔
458
            return { ...item };
459
        });
460
        cascaderPosition[0].offsetY = 4; // 左下
461
        cascaderPosition[1].offsetY = 4; // 右下
49✔
462
        cascaderPosition[2].offsetY = -4; // 右下
49✔
463
        cascaderPosition[3].offsetY = -4; // 右下
464
        this.positions = cascaderPosition;
1✔
465
    }
466

467
    writeValue(value: any): void {
468
        this.thyCascaderService.writeCascaderValue(value);
469
        if (this.isMultiple) {
470
            this.cdr.detectChanges();
471
        }
472
    }
473

1✔
474
    setDisabledState(isDisabled: boolean): void {
475
        this.disabled = isDisabled;
476
    }
477

478
    public positionChange(position: ConnectedOverlayPositionChange): void {
479
        const newValue = position.connectionPair.originY === 'bottom' ? 'bottom' : 'top';
480
        if (this.dropDownPosition !== newValue) {
481
            this.dropDownPosition = newValue;
482
            this.cdr.detectChanges();
483
        }
484
    }
485

486
    public isActivatedOption(option: ThyCascaderOption, index: number): boolean {
487
        return this.thyCascaderService.isActivatedOption(option, index);
488
    }
489

490
    public isHalfSelectedOption(option: ThyCascaderOption, index: number): boolean {
491
        return this.thyCascaderService.isHalfSelectedOption(option, index);
492
    }
493

494
    public isSelectedOption(option: ThyCascaderOption, index: number): boolean {
495
        return this.thyCascaderService.isSelectedOption(option, index);
496
    }
497

498
    public attached(): void {
499
        this.cdr.detectChanges();
500
        this.cdkConnectedOverlay.positionChange.pipe(take(1), takeUntil(this.destroy$)).subscribe(() => {
501
            this.scrollActiveElementIntoView();
502
        });
503
    }
504

505
    private scrollActiveElementIntoView() {
506
        if (!isEmpty(this.thyCascaderService.selectedOptions)) {
507
            const activeOptions = this.cascaderOptions
508
                .filter(item => item.nativeElement.classList.contains('thy-cascader-menu-item-active'))
509
                // for multiple mode
510
                .slice(-this.cascaderOptionContainers.length);
511

512
            this.cascaderOptionContainers.forEach((item, index) => {
1✔
513
                if (index <= activeOptions.length - 1) {
514
                    ScrollToService.scrollToElement(activeOptions[index].nativeElement, item.nativeElement);
515
                    this.cdr.detectChanges();
516
                }
1✔
517
            });
518
        }
519
    }
520

1✔
521
    public setMenuVisible(menuVisible: boolean): void {
522
        if (this.menuVisible !== menuVisible) {
523
            this.menuVisible = menuVisible;
524

525
            this.thyCascaderService.initActivatedOptions(this.menuVisible);
1✔
526
            this.setClassMap();
527
            this.setMenuClass();
528
            if (this.menuVisible) {
529
                this.triggerRect = this.trigger.nativeElement.getBoundingClientRect();
1✔
530
            }
531
            this.thyExpandStatusChange.emit(menuVisible);
532
        }
533
    }
1✔
534

535
    public get menuCls(): any {
536
        return this._menuCls;
537
    }
1✔
538

539
    private setMenuClass(): void {
540
        this._menuCls = {
541
            [`${this.prefixCls}-menus`]: true,
542
            [`${this.prefixCls}-menus-hidden`]: !this.menuVisible,
543
            [`${this.thyMenuClassName}`]: this.thyMenuClassName,
544
            [`w-100`]: this.columns.length === 0
49✔
545
        };
546
    }
547

548
    public get menuColumnCls(): any {
549
        return this._menuColumnCls;
550
    }
551

552
    private setMenuColumnClass(): void {
553
        this._menuColumnCls = {
554
            [`${this.prefixCls}-menu`]: true,
555
            [`${this.thyColumnClassName}`]: this.thyColumnClassName
556
        };
557
    }
558

559
    public get labelCls(): any {
560
        return this._labelCls;
561
    }
562

563
    private setLabelClass(): void {
564
        this._labelCls = {
565
            [`${this.prefixCls}-picker-label`]: true,
566
            [`${this.prefixCls}-show-search`]: false,
567
            [`${this.prefixCls}-focused`]: false,
568
            'text-truncate': true
569
        };
570
    }
571

572
    private setClassMap(): void {
573
        const classMap = {
574
            [`${this.prefixCls}`]: true,
575
            [`${this.prefixCls}-picker`]: true,
576
            [`${this.prefixCls}-${this.thySize}`]: true,
577
            [`${this.prefixCls}-picker-disabled`]: this.disabled,
578
            [`${this.prefixCls}-picker-open`]: this.menuVisible
579
        };
580
        this.hostRenderer.updateClassByMap(classMap);
581
    }
582

583
    private isClickTriggerAction(): boolean {
584
        if (typeof this.thyTriggerAction === 'string') {
585
            return this.thyTriggerAction === 'click';
586
        }
587
        return this.thyTriggerAction.indexOf('click') !== -1;
588
    }
589

590
    private isHoverTriggerAction(): boolean {
591
        if (typeof this.thyTriggerAction === 'string') {
592
            return this.thyTriggerAction === 'hover';
593
        }
594
        return this.thyTriggerAction.indexOf('hover') !== -1;
595
    }
596

597
    private isHoverExpandTriggerAction(): boolean {
598
        if (typeof this.thyExpandTriggerAction === 'string') {
599
            return this.thyExpandTriggerAction === 'hover';
600
        }
601
        return this.thyExpandTriggerAction.indexOf('hover') !== -1;
602
    }
603

604
    @HostListener('click', ['$event'])
605
    public toggleClick($event: Event) {
606
        if (this.disabled) {
607
            return;
608
        }
609
        if (this.isClickTriggerAction()) {
610
            this.setMenuVisible(!this.menuVisible);
611
        }
612
    }
613

614
    @HostListener('mouseover', ['$event'])
615
    public toggleHover($event: Event) {
616
        if (this.disabled) {
617
            return;
618
        }
619
        if (this.isHoverTriggerAction()) {
620
            this.setMenuVisible(!this.menuVisible);
621
        }
622
    }
623

624
    public clickOption(option: ThyCascaderOption, index: number, event: Event | boolean): void {
625
        this.thyCascaderService.clickOption(option, index, event, this.selectOption);
626
    }
627

628
    public mouseoverOption(option: ThyCascaderOption, index: number, event: Event): void {
629
        if (event) {
630
            event.preventDefault();
631
        }
632

633
        if (option && option.disabled && !this.isMultiple) {
634
            return;
635
        }
636

637
        if (!this.isHoverExpandTriggerAction() && !(option && option.disabled && this.isMultiple)) {
638
            return;
639
        }
640
        this.setActiveOption(option, index, false);
641
    }
642

643
    public mouseleaveMenu(event: Event) {
644
        if (event) {
645
            event.preventDefault();
646
        }
647
        if (!this.isHoverTriggerAction()) {
648
            return;
649
        }
650
        this.setMenuVisible(!this.menuVisible);
651
    }
652

653
    onBlur(event?: FocusEvent) {
654
        // Tab 聚焦后自动聚焦到 input 输入框,此分支下直接返回,无需触发 onTouchedFn
655
        if (elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-cascader-menus', 'thy-cascader'])) {
656
            return;
657
        }
658
        this.onTouchedFn();
659
    }
660

661
    onFocus(event?: FocusEvent) {
662
        if (!elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-cascader-menus', 'thy-cascader'])) {
663
            const inputElement: HTMLInputElement = this.elementRef.nativeElement.querySelector('input');
664
            inputElement.focus();
665
        }
666
    }
667

668
    public closeMenu(): void {
669
        if (this.menuVisible) {
670
            this.setMenuVisible(false);
671
            this.onTouchedFn();
672
            this.isShowSearchPanel = false;
673
            this.thyCascaderService.searchResultList = [];
674
        }
675
    }
676

677
    public setActiveOption(option: ThyCascaderOption, index: number, select: boolean, loadChildren: boolean = true): void {
678
        this.thyCascaderService.setActiveOption(option, index, select, loadChildren, this.selectOption);
679
    }
680

681
    private selectOption = (option: ThyCascaderOption, index: number) => {
682
        this.thySelect.emit({ option, index });
683
        const isOptionCanSelect = this.thyChangeOnSelect && !this.isMultiple;
684
        if (option.isLeaf || !this.thyIsOnlySelectLeaf || isOptionCanSelect || this.shouldPerformSelection(option, index)) {
685
            this.thyCascaderService.selectOption(option, index);
686
        }
687
        if ((option.isLeaf || !this.thyIsOnlySelectLeaf) && !this.thyMultiple) {
688
            this.setMenuVisible(false);
689
            this.onTouchedFn();
690
        }
691
    };
692

693
    public removeSelectedItem(event: { item: SelectOptionBase; $eventOrigin: Event }) {
694
        event.$eventOrigin.stopPropagation();
695
        this.thyCascaderService.removeSelectedItem(event?.item);
696
    }
697

698
    private shouldPerformSelection(option: ThyCascaderOption, level: number): boolean {
699
        return typeof this.thyChangeOn === 'function' ? this.thyChangeOn(option, level) === true : false;
700
    }
701

702
    public clearSelection($event: Event): void {
703
        if ($event) {
704
            $event.stopPropagation();
705
            $event.preventDefault();
706
        }
707
        this.thyCascaderService.clearSelection();
708
        this.setMenuVisible(false);
709
    }
710

711
    constructor(
712
        @Inject(PLATFORM_ID) private platformId: string,
713
        private cdr: ChangeDetectorRef,
714
        private viewPortRuler: ViewportRuler,
715
        public elementRef: ElementRef,
716
        private thyClickDispatcher: ThyClickDispatcher,
717
        private ngZone: NgZone,
718
        public thyCascaderService: ThyCascaderService
719
    ) {
720
        super();
721
    }
722

723
    public trackByFn(index: number, item: ThyCascaderOption) {
724
        return item?.value || item?._id || index;
725
    }
726

727
    public searchFilter(searchText: string) {
728
        if (!searchText && !this.isSelectingSearchState) {
729
            this.resetSearch();
730
        }
731
        this.searchText$.next(searchText);
732
    }
733

734
    private initSearch() {
735
        this.searchText$
736
            .pipe(
737
                takeUntil(this.destroy$),
738
                debounceTime(200),
739
                distinctUntilChanged(),
740
                filter(text => text !== '')
741
            )
742
            .subscribe(searchText => {
743
                this.resetSearch();
744

745
                // local search
746
                this.searchInLocal(searchText);
747
                this.isShowSearchPanel = true;
748
            });
749
    }
750

751
    private searchInLocal(searchText: string): void {
752
        this.thyCascaderService.searchInLocal(searchText);
753
    }
754

755
    private resetSearch() {
756
        this.isShowSearchPanel = false;
757
        this.thyCascaderService.resetSearch();
758
        this.scrollActiveElementIntoView();
759
    }
760

761
    public selectSearchResult(selectOptionData: ThyCascaderSearchOption): void {
762
        const { thyRowValue: selectedOptions } = selectOptionData;
763
        if (selectOptionData.selected) {
764
            if (!this.isMultiple) {
765
                this.closeMenu();
766
            }
767
            return;
768
        }
769
        selectedOptions.forEach((item: ThyCascaderOption, index: number) => {
770
            this.setActiveOption(item, index, index === selectedOptions.length - 1);
771
        });
772
        if (this.isMultiple) {
773
            this.isSelectingSearchState = true;
774
            selectOptionData.selected = true;
775
            const originSearchResultList = this.searchResultList;
776
            // 保持搜索选项
777
            setTimeout(() => {
778
                this.isShowSearchPanel = true;
779
                this.thyCascaderService.searchResultList = originSearchResultList;
780
                this.isSelectingSearchState = false;
781
            });
782
        } else {
783
            this.resetSearch();
784
        }
785
    }
786

787
    ngOnDestroy() {
788
        this.destroy$.next();
789
        this.destroy$.complete();
790
    }
791
}
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