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

atinc / ngx-tethys / cc6b388f-2c7e-4a1b-9dea-fbbcd7036c50

15 Dec 2023 09:28AM UTC coverage: 90.471% (+0.06%) from 90.407%
cc6b388f-2c7e-4a1b-9dea-fbbcd7036c50

Pull #2894

circleci

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

5383 of 6614 branches covered (0.0%)

Branch coverage included in aggregate %.

296 of 308 new or added lines in 2 files covered. (96.1%)

18 existing lines in 1 file now uncovered.

13444 of 14196 relevant lines covered (94.7%)

967.85 hits per line

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

86.36
/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, timer } 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 {
24
    AfterContentInit,
1✔
25
    ChangeDetectorRef,
26
    Component,
56✔
27
    ElementRef,
56✔
28
    EventEmitter,
56✔
29
    forwardRef,
4✔
30
    HostListener,
31
    Inject,
32
    Input,
33
    NgZone,
3✔
34
    OnChanges,
3✔
35
    OnDestroy,
3✔
36
    OnInit,
37
    Output,
38
    PLATFORM_ID,
4✔
39
    QueryList,
40
    SimpleChanges,
41
    TemplateRef,
34✔
42
    ViewChild,
43
    ViewChildren
44
} from '@angular/core';
49✔
45
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
46
import { useHostRenderer } from '@tethys/cdk/dom';
47

31✔
48
import { ThyCascaderOptionComponent } from './cascader-li.component';
31✔
49
import { ThyCascaderSearchOptionComponent } from './cascader-search-option.component';
50
import { ThyCascaderExpandTrigger, ThyCascaderOption, ThyCascaderSearchOption, ThyCascaderTriggerType } from './types';
51
import { ThyCascaderService } from './cascader.service';
328✔
52
import { scaleYMotion } from 'ngx-tethys/core';
53

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

100
    /**
101
     * 选项的显示值的属性名
102
     */
103
    @Input() thyLabelProperty = 'label';
104

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

×
UNCOV
110
    /**
×
UNCOV
111
     * 控制大小(4种)
×
112
     * @type 'sm' | 'md' | 'lg' | ''
113
     */
114
    @Input() thySize: SelectControlSize = '';
49✔
115

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

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

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

141
    /**
49✔
142
     * 显示输入框
31✔
143
     * @type boolean
31✔
144
     */
31✔
145
    @Input() @InputBoolean() thyShowInput = true;
146

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

49✔
158
    get thyLabelRender(): TemplateRef<any> {
49✔
159
        return this.labelRenderTpl;
49✔
160
    }
49✔
161

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

169
    get thyLoadData() {
170
        return this.thyCascaderService?.cascaderOptions?.loadData;
49✔
171
    }
172

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

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

185
    /**
186
     * 自定义浮层样式
1,477✔
187
     */
188
    @Input() thyMenuStyle: { [key: string]: string };
189

36✔
190
    /**
36✔
191
     * 自定义浮层类名
36✔
192
     * @type string
193
     */
194
    @Input()
195
    set thyMenuClassName(value: string) {
44✔
196
        this.menuClassName = value;
16✔
197
        this.setMenuClass();
83✔
198
    }
199

200
    get thyMenuClassName(): string {
16✔
201
        return this.menuClassName;
34✔
202
    }
30✔
203

30✔
204
    /**
205
     * 自定义浮层列类名
206
     * @type string
207
     */
208
    @Input()
209
    set thyColumnClassName(value: string) {
71✔
210
        this.columnClassName = value;
53✔
211
        this.setMenuClass();
53✔
212
    }
53✔
213

53✔
214
    get thyColumnClassName(): string {
53✔
215
        return this.columnClassName;
46✔
216
    }
217

53✔
218
    /**
219
     * 是否只读
220
     * @default false
221
     */
296✔
222
    @Input()
223
    // eslint-disable-next-line prettier/prettier
224
    override get thyDisabled(): boolean {
164✔
225
        return this.disabled;
226
    }
227

228
    override set thyDisabled(value: boolean) {
229
        this.disabled = coerceBooleanProperty(value);
230
    }
231

232
    disabled = false;
550✔
233

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

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

255
    get thyMultiple(): boolean {
256
        return this.isMultiple;
257
    }
258

259
    /**
102✔
260
     * 设置多选时最大显示的标签数量,0 表示不限制
261
     * @type number
262
     */
31✔
263
    @Input() @InputNumber() thyMaxTagCount = 0;
21✔
264

265
    /**
10✔
266
     * 是否仅允许选择叶子项
267
     * @default true
268
     */
4!
269
    @Input()
4✔
270
    @InputBoolean()
UNCOV
271
    thyIsOnlySelectLeaf = true;
×
272

273
    /**
274
     * 初始化时,是否展开面板
2!
275
     * @default false
2✔
276
     */
UNCOV
277
    @Input() @InputBoolean() thyAutoExpand: boolean;
×
278

279
    /**
280
     * 是否支持搜索
33✔
281
     * @default false
2✔
282
     */
283
    @Input() @InputBoolean() thyShowSearch: boolean = false;
31!
284

31✔
285
    /**
286
     * 多选选中项的展示方式,默认为空,渲染文字模板,传入tag,渲染展示模板,
287
     * @default ''|tag
288
     */
2!
289
    @Input() thyPreset: string = '';
×
290

291
    /**
2!
292
     * 值发生变化时触发,返回选择项的值
2✔
293
     * @type EventEmitter<any[]>
294
     */
295
    @Output() thyChange = new EventEmitter<any[]>();
296

20✔
297
    /**
298
     * 值发生变化时触发,返回选择项列表
299
     * @type EventEmitter<ThyCascaderOption[]>
2!
300
     */
2✔
301
    @Output() thySelectionChange = new EventEmitter<ThyCascaderOption[]>();
302

2!
UNCOV
303
    /**
×
304
     * 选择选项时触发
305
     */
2!
306
    @Output() thySelect = new EventEmitter<{
1✔
307
        option: ThyCascaderOption;
308
        index: number;
1✔
309
    }>();
310

311
    /**
2!
312
     * @private 暂无实现
2✔
313
     */
314
    @Output() thyDeselect = new EventEmitter<{
2✔
315
        option: ThyCascaderOption;
1✔
316
        index: number;
317
    }>();
1✔
318

319
    /**
320
     * 清空选项时触发
321
     */
1!
UNCOV
322
    @Output() thyClear = new EventEmitter<void>();
×
323

324
    /**
1✔
325
     * 下拉选项展开和折叠状态事件
326
     */
327
    @Output() thyExpandStatusChange: EventEmitter<boolean> = new EventEmitter<boolean>();
1!
328

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

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

333
    @ViewChild(CdkConnectedOverlay, { static: true }) cdkConnectedOverlay: CdkConnectedOverlay;
8✔
334

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

1✔
337
    @ViewChild('input') input: ElementRef;
1✔
338

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

4✔
341
    public dropDownPosition = 'bottom';
4✔
342

343
    public menuVisible = false;
344

1✔
345
    public isLabelRenderTemplate = false;
1✔
346

347
    public triggerRect: DOMRect;
NEW
348

×
349
    public emptyStateText = '暂无可选项';
350

351
    private prefixCls = 'thy-cascader';
1!
352

1✔
353
    private menuClassName: string;
1✔
354

355
    private columnClassName: string;
1✔
356

1✔
357
    private _menuColumnCls: any;
358

359
    private readonly destroy$ = new Subject<void>();
49✔
360

49✔
361
    private _menuCls: { [name: string]: any };
49✔
362

49✔
363
    private _labelCls: { [name: string]: any };
49✔
364

49✔
365
    private labelRenderTpl: TemplateRef<any>;
49✔
366

49✔
367
    private hostRenderer = useHostRenderer();
49✔
368

49✔
369
    public positions: ConnectionPositionPair[];
49✔
370

49✔
371
    get selected(): SelectOptionBase | SelectOptionBase[] {
49✔
372
        this.cdkConnectedOverlay?.overlayRef?.updatePosition();
49✔
373
        return this.thyMultiple ? this.thyCascaderService.selectionModel.selected : this.thyCascaderService.selectionModel.selected[0];
49✔
374
    }
49✔
375
    private isMultiple = false;
49✔
376

49✔
377
    public menuMinWidth = 122;
49✔
378

49✔
379
    private searchText$ = new BehaviorSubject('');
49✔
380

49✔
381
    public get searchResultList(): ThyCascaderSearchOption[] {
49✔
382
        return this.thyCascaderService.searchResultList;
49✔
383
    }
49✔
384

49✔
385
    public isShowSearchPanel: boolean = false;
49✔
386

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

49✔
395
    public get isLoading() {
49✔
396
        return this.thyCascaderService?.isLoading;
49✔
397
    }
398

399
    public get columns() {
400
        return this.thyCascaderService.columns;
401
    }
402

403
    ngOnInit(): void {
49✔
404
        this.setClassMap();
49✔
405
        this.setMenuClass();
9✔
406
        this.setMenuColumnClass();
9✔
407
        this.setLabelClass();
9!
408
        this.initPosition();
9✔
409
        this.initSearch();
410
        const options = {
9✔
411
            labelProperty: this.thyLabelProperty,
4✔
412
            valueProperty: this.thyValueProperty,
4✔
413
            isMultiple: this.isMultiple,
414
            isOnlySelectLeaf: this.thyIsOnlySelectLeaf,
415
            isLabelRenderTemplate: this.isLabelRenderTemplate,
416
            loadData: this.thyLoadData
417
        };
997!
418
        this.thyCascaderService.setCascaderOptions(options);
419

420
        this.viewPortRuler
7✔
421
            .change(100)
1✔
422
            .pipe(takeUntil(this.destroy$))
423
            .subscribe(() => {
7✔
424
                if (this.menuVisible) {
425
                    this.triggerRect = this.trigger.nativeElement.getBoundingClientRect();
426
                    this.cdr.markForCheck();
49✔
427
                }
50✔
428
            });
429

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

UNCOV
459
    ngAfterContentInit() {
×
UNCOV
460
        if (this.thyAutoExpand) {
×
UNCOV
461
            timer(0).subscribe(() => {
×
UNCOV
462
                this.cdr.markForCheck();
×
463
                this.setMenuVisible(true);
464
            });
465
        }
466
    }
1✔
467

468
    ngOnChanges(changes: SimpleChanges): void {
469
        if (changes['thyIsOnlySelectLeaf']) {
470
            this.thyCascaderService.setCascaderOptions({ isOnlySelectLeaf: changes['thyIsOnlySelectLeaf'].currentValue });
49✔
471
        }
49✔
472
    }
473

1✔
474
    private initPosition() {
475
        const cascaderPosition: ConnectionPositionPair[] = EXPANDED_DROPDOWN_POSITIONS.map(item => {
476
            return { ...item };
477
        });
478
        cascaderPosition[0].offsetY = 4; // 左下
479
        cascaderPosition[1].offsetY = 4; // 右下
480
        cascaderPosition[2].offsetY = -4; // 右下
481
        cascaderPosition[3].offsetY = -4; // 右下
482
        this.positions = cascaderPosition;
1✔
483
    }
484

485
    writeValue(value: any): void {
486
        this.thyCascaderService.writeCascaderValue(value);
487
        if (this.isMultiple) {
488
            this.cdr.detectChanges();
489
        }
490
    }
491

492
    setDisabledState(isDisabled: boolean): void {
493
        this.disabled = isDisabled;
494
    }
495

496
    public positionChange(position: ConnectedOverlayPositionChange): void {
497
        const newValue = position.connectionPair.originY === 'bottom' ? 'bottom' : 'top';
498
        if (this.dropDownPosition !== newValue) {
499
            this.dropDownPosition = newValue;
500
            this.cdr.detectChanges();
501
        }
502
    }
503

504
    public isActivatedOption(option: ThyCascaderOption, index: number): boolean {
505
        return this.thyCascaderService.isActivatedOption(option, index);
506
    }
507

508
    public isHalfSelectedOption(option: ThyCascaderOption, index: number): boolean {
509
        return this.thyCascaderService.isHalfSelectedOption(option, index);
510
    }
511

512
    public isSelectedOption(option: ThyCascaderOption, index: number): boolean {
513
        return this.thyCascaderService.isSelectedOption(option, index);
514
    }
515

516
    public attached(): void {
517
        this.cdr.detectChanges();
518
        this.cdkConnectedOverlay.positionChange.pipe(take(1), takeUntil(this.destroy$)).subscribe(() => {
519
            this.scrollActiveElementIntoView();
520
        });
521
    }
522

1✔
523
    private scrollActiveElementIntoView() {
524
        if (!isEmpty(this.thyCascaderService.selectedOptions)) {
525
            const activeOptions = this.cascaderOptions
526
                .filter(item => item.nativeElement.classList.contains('thy-cascader-menu-item-active'))
1✔
527
                // for multiple mode
528
                .slice(-this.cascaderOptionContainers.length);
529

530
            this.cascaderOptionContainers.forEach((item, index) => {
1✔
531
                if (index <= activeOptions.length - 1) {
532
                    ScrollToService.scrollToElement(activeOptions[index].nativeElement, item.nativeElement);
533
                    this.cdr.detectChanges();
534
                }
535
            });
1✔
536
        }
537
    }
538

539
    public setMenuVisible(menuVisible: boolean): void {
1✔
540
        if (this.menuVisible !== menuVisible) {
541
            this.menuVisible = menuVisible;
542

543
            this.thyCascaderService.initActivatedOptions(this.menuVisible);
1✔
544
            this.setClassMap();
545
            this.setMenuClass();
546
            if (this.menuVisible) {
547
                this.triggerRect = this.trigger.nativeElement.getBoundingClientRect();
1✔
548
            }
549
            this.thyExpandStatusChange.emit(menuVisible);
550
        }
551
    }
1✔
552

553
    public get menuCls(): any {
554
        return this._menuCls;
555
    }
556

557
    private setMenuClass(): void {
558
        this._menuCls = {
49✔
559
            [`${this.prefixCls}-menus`]: true,
560
            [`${this.prefixCls}-menus-hidden`]: !this.menuVisible,
561
            [`${this.thyMenuClassName}`]: this.thyMenuClassName,
562
            [`w-100`]: this.columns.length === 0
563
        };
564
    }
565

566
    public get menuColumnCls(): any {
567
        return this._menuColumnCls;
568
    }
569

570
    private setMenuColumnClass(): void {
571
        this._menuColumnCls = {
572
            [`${this.prefixCls}-menu`]: true,
573
            [`${this.thyColumnClassName}`]: this.thyColumnClassName
574
        };
575
    }
576

577
    public get labelCls(): any {
578
        return this._labelCls;
579
    }
580

581
    private setLabelClass(): void {
582
        this._labelCls = {
583
            [`${this.prefixCls}-picker-label`]: true,
584
            [`${this.prefixCls}-show-search`]: false,
585
            [`${this.prefixCls}-focused`]: false,
586
            'text-truncate': true
587
        };
588
    }
589

590
    private setClassMap(): void {
591
        const classMap = {
592
            [`${this.prefixCls}`]: true,
593
            [`${this.prefixCls}-picker`]: true,
594
            [`${this.prefixCls}-${this.thySize}`]: true,
595
            [`${this.prefixCls}-picker-disabled`]: this.disabled,
596
            [`${this.prefixCls}-picker-open`]: this.menuVisible
597
        };
598
        this.hostRenderer.updateClassByMap(classMap);
599
    }
600

601
    private isClickTriggerAction(): boolean {
602
        if (typeof this.thyTriggerAction === 'string') {
603
            return this.thyTriggerAction === 'click';
604
        }
605
        return this.thyTriggerAction.indexOf('click') !== -1;
606
    }
607

608
    private isHoverTriggerAction(): boolean {
609
        if (typeof this.thyTriggerAction === 'string') {
610
            return this.thyTriggerAction === 'hover';
611
        }
612
        return this.thyTriggerAction.indexOf('hover') !== -1;
613
    }
614

615
    private isHoverExpandTriggerAction(): boolean {
616
        if (typeof this.thyExpandTriggerAction === 'string') {
617
            return this.thyExpandTriggerAction === 'hover';
618
        }
619
        return this.thyExpandTriggerAction.indexOf('hover') !== -1;
620
    }
621

622
    @HostListener('click', ['$event'])
623
    public toggleClick($event: Event) {
624
        if (this.disabled) {
625
            return;
626
        }
627
        if (this.isClickTriggerAction()) {
628
            this.setMenuVisible(!this.menuVisible);
629
        }
630
    }
631

632
    @HostListener('mouseover', ['$event'])
633
    public toggleHover($event: Event) {
634
        if (this.disabled) {
635
            return;
636
        }
637
        if (this.isHoverTriggerAction()) {
638
            this.setMenuVisible(!this.menuVisible);
639
        }
640
    }
641

642
    public clickOption(option: ThyCascaderOption, index: number, event: Event | boolean): void {
643
        this.thyCascaderService.clickOption(option, index, event, this.selectOption);
644
    }
645

646
    public mouseoverOption(option: ThyCascaderOption, index: number, event: Event): void {
647
        if (event) {
648
            event.preventDefault();
649
        }
650

651
        if (option && option.disabled && !this.isMultiple) {
652
            return;
653
        }
654

655
        if (!this.isHoverExpandTriggerAction() && !(option && option.disabled && this.isMultiple)) {
656
            return;
657
        }
658
        this.setActiveOption(option, index, false);
659
    }
660

661
    public mouseleaveMenu(event: Event) {
662
        if (event) {
663
            event.preventDefault();
664
        }
665
        if (!this.isHoverTriggerAction()) {
666
            return;
667
        }
668
        this.setMenuVisible(!this.menuVisible);
669
    }
670

671
    onBlur(event?: FocusEvent) {
672
        // Tab 聚焦后自动聚焦到 input 输入框,此分支下直接返回,无需触发 onTouchedFn
673
        if (elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-cascader-menus', 'thy-cascader'])) {
674
            return;
675
        }
676
        this.onTouchedFn();
677
    }
678

679
    onFocus(event?: FocusEvent) {
680
        if (!elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-cascader-menus', 'thy-cascader'])) {
681
            const inputElement: HTMLInputElement = this.elementRef.nativeElement.querySelector('input');
682
            inputElement.focus();
683
        }
684
    }
685

686
    public closeMenu(): void {
687
        if (this.menuVisible) {
688
            this.setMenuVisible(false);
689
            this.onTouchedFn();
690
            this.isShowSearchPanel = false;
691
            this.thyCascaderService.searchResultList = [];
692
        }
693
    }
694

695
    public setActiveOption(option: ThyCascaderOption, index: number, select: boolean, loadChildren: boolean = true): void {
696
        this.thyCascaderService.setActiveOption(option, index, select, loadChildren, this.selectOption);
697
    }
698

699
    private selectOption = (option: ThyCascaderOption, index: number) => {
700
        this.thySelect.emit({ option, index });
701
        const isOptionCanSelect = this.thyChangeOnSelect && !this.isMultiple;
702
        if (option.isLeaf || !this.thyIsOnlySelectLeaf || isOptionCanSelect || this.shouldPerformSelection(option, index)) {
703
            this.thyCascaderService.selectOption(option, index);
704
        }
705
        if ((option.isLeaf || !this.thyIsOnlySelectLeaf) && !this.thyMultiple) {
706
            this.setMenuVisible(false);
707
            this.onTouchedFn();
708
        }
709
    };
710

711
    public removeSelectedItem(event: { item: SelectOptionBase; $eventOrigin: Event }) {
712
        event.$eventOrigin.stopPropagation();
713
        this.thyCascaderService.removeSelectedItem(event?.item);
714
    }
715

716
    private shouldPerformSelection(option: ThyCascaderOption, level: number): boolean {
717
        return typeof this.thyChangeOn === 'function' ? this.thyChangeOn(option, level) === true : false;
718
    }
719

720
    public clearSelection($event: Event): void {
721
        if ($event) {
722
            $event.stopPropagation();
723
            $event.preventDefault();
724
        }
725
        this.thyCascaderService.clearSelection();
726
        this.setMenuVisible(false);
727
    }
728

729
    constructor(
730
        @Inject(PLATFORM_ID) private platformId: string,
731
        private cdr: ChangeDetectorRef,
732
        private viewPortRuler: ViewportRuler,
733
        public elementRef: ElementRef,
734
        private thyClickDispatcher: ThyClickDispatcher,
735
        private ngZone: NgZone,
736
        public thyCascaderService: ThyCascaderService
737
    ) {
738
        super();
739
    }
740

741
    public trackByFn(index: number, item: ThyCascaderOption) {
742
        return item?.value || item?._id || index;
743
    }
744

745
    public searchFilter(searchText: string) {
746
        if (!searchText && !this.isSelectingSearchState) {
747
            this.resetSearch();
748
        }
749
        this.searchText$.next(searchText);
750
    }
751

752
    private initSearch() {
753
        this.searchText$
754
            .pipe(
755
                takeUntil(this.destroy$),
756
                debounceTime(200),
757
                distinctUntilChanged(),
758
                filter(text => text !== '')
759
            )
760
            .subscribe(searchText => {
761
                this.resetSearch();
762

763
                // local search
764
                this.searchInLocal(searchText);
765
                this.isShowSearchPanel = true;
766
            });
767
    }
768

769
    private searchInLocal(searchText: string): void {
770
        this.thyCascaderService.searchInLocal(searchText);
771
    }
772

773
    private resetSearch() {
774
        this.isShowSearchPanel = false;
775
        this.thyCascaderService.resetSearch();
776
        this.scrollActiveElementIntoView();
777
    }
778

779
    public selectSearchResult(selectOptionData: ThyCascaderSearchOption): void {
780
        const { thyRowValue: selectedOptions } = selectOptionData;
781
        if (selectOptionData.selected) {
782
            if (!this.isMultiple) {
783
                this.closeMenu();
784
            }
785
            return;
786
        }
787
        selectedOptions.forEach((item: ThyCascaderOption, index: number) => {
788
            this.setActiveOption(item, index, index === selectedOptions.length - 1);
789
        });
790
        if (this.isMultiple) {
791
            this.isSelectingSearchState = true;
792
            selectOptionData.selected = true;
793
            const originSearchResultList = this.searchResultList;
794
            // 保持搜索选项
795
            setTimeout(() => {
796
                this.isShowSearchPanel = true;
797
                this.thyCascaderService.searchResultList = originSearchResultList;
798
                this.isSelectingSearchState = false;
799
            });
800
        } else {
801
            this.resetSearch();
802
        }
803
    }
804

805
    ngOnDestroy() {
806
        this.destroy$.next();
807
        this.destroy$.complete();
808
    }
809
}
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