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

atinc / ngx-tethys / 8f668ed3-540e-42d0-b2be-8926bd11a549

28 Dec 2023 09:27AM UTC coverage: 90.575% (+0.003%) from 90.572%
8f668ed3-540e-42d0-b2be-8926bd11a549

Pull #3003

circleci

smile1016
fix(cascader): set cdkConnectedOverlayHasBackdrop is true #INFR-11119
Pull Request #3003: fix(cascader): set cdkConnectedOverlayHasBackdrop is true #INFR-11119

5408 of 6628 branches covered (0.0%)

Branch coverage included in aggregate %.

13494 of 14241 relevant lines covered (94.75%)

981.17 hits per line

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

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

4✔
39
import { ThyCascaderOptionComponent } from './cascader-li.component';
40
import { ThyCascaderSearchOptionComponent } from './cascader-search-option.component';
41
import { ThyCascaderExpandTrigger, ThyCascaderOption, ThyCascaderSearchOption, ThyCascaderTriggerType } from './types';
35✔
42
import { ThyCascaderService } from './cascader.service';
43
import { scaleYMotion } from 'ngx-tethys/core';
44

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

49✔
91
    /**
49✔
92
     * 选项的显示值的属性名
49✔
93
     */
49✔
94
    @Input() thyLabelProperty = 'label';
49✔
95

49✔
96
    /**
97
     * 描述输入字段预期值的简短的提示信息
98
     */
99
    @Input() thyPlaceholder = '请选择';
100

101
    /**
102
     * 控制大小(4种)
103
     * @type 'sm' | 'md' | 'lg' | ''
49✔
104
     */
49✔
105
    @Input() thySize: SelectControlSize = '';
9!
106

9✔
107
    /**
9✔
108
     * 数据项
1✔
109
     * @type ThyCascaderOption[]
110
     * @default []
9✔
111
     */
9✔
112
    @Input()
9✔
113
    set thyOptions(options: ThyCascaderOption[] | null) {
5✔
114
        const columns = options && options.length ? [options] : [];
5✔
115
        this.thyCascaderService.initColumns(columns);
116
        if (this.thyCascaderService.defaultValue && columns.length) {
117
            this.thyCascaderService.initOptions(0);
118
        }
119
    }
120

49✔
121
    /**
32✔
122
     * 点击父级菜单选项时,可通过该函数判断是否允许值的变化
32✔
123
     */
32✔
124
    @Input() thyChangeOn: (option: ThyCascaderOption, level: number) => boolean;
125

126
    /**
127
     * 点击项时,表单是否动态展示数据项
128
     * @type boolean
75✔
129
     */
35✔
130
    @Input() @InputBoolean() thyChangeOnSelect = false;
131

132
    /**
133
     * 显示输入框
49✔
134
     * @type boolean
196✔
135
     */
136
    @Input() @InputBoolean() thyShowInput = true;
49✔
137

49✔
138
    /**
49✔
139
     * 用户自定义模板
49✔
140
     * @type TemplateRef
49✔
141
     */
142
    @Input()
143
    set thyLabelRender(value: TemplateRef<any>) {
108✔
144
        this.labelRenderTpl = value;
108✔
145
        this.isLabelRenderTemplate = value instanceof TemplateRef;
29✔
146
        this.thyCascaderService.setCascaderOptions({ isLabelRenderTemplate: this.isLabelRenderTemplate });
147
    }
148

149
    get thyLabelRender(): TemplateRef<any> {
49✔
150
        return this.labelRenderTpl;
151
    }
152

25✔
153
    /**
25✔
154
     * 用于动态加载选项
1✔
155
     */
1✔
156
    @Input() set thyLoadData(value: (node: ThyCascaderOption, index?: number) => PromiseLike<any>) {
157
        this.thyCascaderService.setCascaderOptions({ loadData: value });
158
    }
159

1,595✔
160
    get thyLoadData() {
161
        return this.thyCascaderService?.cascaderOptions?.loadData;
162
    }
1,595✔
163

164
    /**
165
     * 控制触发状态, 支持 `click` | `hover`
1,595✔
166
     * @type ThyCascaderTriggerType | ThyCascaderTriggerType[]
167
     */
168
    @Input() thyTriggerAction: ThyCascaderTriggerType | ThyCascaderTriggerType[] = ['click'];
35✔
169

35✔
170
    /**
15✔
171
     * 鼠标经过下方列表项时,是否自动展开列表,支持 `click` | `hover`
172
     * @type ThyCascaderExpandTrigger | ThyCascaderExpandTrigger[]
173
     */
174
    @Input() thyExpandTriggerAction: ThyCascaderExpandTrigger | ThyCascaderExpandTrigger[] = ['click'];
23✔
175

10✔
176
    /**
67✔
177
     * 自定义浮层样式
178
     */
179
    @Input() thyMenuStyle: { [key: string]: string };
10✔
180

25✔
181
    /**
22✔
182
     * 自定义浮层类名
22✔
183
     * @type string
184
     */
185
    @Input()
186
    set thyMenuClassName(value: string) {
187
        this.menuClassName = value;
188
        this.setMenuClass();
73✔
189
    }
53✔
190

53✔
191
    get thyMenuClassName(): string {
53✔
192
        return this.menuClassName;
53✔
193
    }
53✔
194

45✔
195
    /**
45✔
196
     * 自定义浮层列类名
197
     * @type string
198
     */
8✔
199
    @Input()
200
    set thyColumnClassName(value: string) {
53✔
201
        this.columnClassName = value;
202
        this.setMenuClass();
203
    }
204

322✔
205
    get thyColumnClassName(): string {
206
        return this.columnClassName;
207
    }
166✔
208

209
    /**
210
     * 是否只读
211
     * @default false
212
     */
213
    @Input()
214
    // eslint-disable-next-line prettier/prettier
215
    override get thyDisabled(): boolean {
602✔
216
        return this.disabled;
217
    }
218

49✔
219
    override set thyDisabled(value: boolean) {
220
        this.disabled = coerceBooleanProperty(value);
221
    }
222

223
    disabled = false;
224

409✔
225
    /**
226
     * 空状态下的展示文字
227
     * @default 暂无可选项
49✔
228
     */
229
    @Input()
230
    set thyEmptyStateText(value: string) {
231
        this.emptyStateText = value;
232
    }
233

234
    /**
235
     * 是否多选
102✔
236
     * @type boolean
237
     * @default false
238
     */
239
    @Input()
240
    @InputBoolean()
241
    set thyMultiple(value: boolean) {
242
        this.isMultiple = value;
102✔
243
        this.thyCascaderService.setCascaderOptions({ isMultiple: value });
244
    }
245

31✔
246
    get thyMultiple(): boolean {
22✔
247
        return this.isMultiple;
248
    }
9✔
249

250
    /**
251
     * 设置多选时最大显示的标签数量,0 表示不限制
4!
252
     * @type number
4✔
253
     */
254
    @Input() @InputNumber() thyMaxTagCount = 0;
×
255

256
    /**
257
     * 是否仅允许选择叶子项
2!
258
     * @default true
2✔
259
     */
260
    @Input()
×
261
    @InputBoolean()
262
    thyIsOnlySelectLeaf = true;
263

33✔
264
    /**
2✔
265
     * 初始化时,是否展开面板
266
     * @default false
31!
267
     */
31✔
268
    @Input() @InputBoolean() thyAutoExpand: boolean;
269

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

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

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

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

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

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

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

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

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

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

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

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

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

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

×
332
    public dropDownPosition = 'bottom';
333

334
    public menuVisible = false;
1!
335

1✔
336
    public isLabelRenderTemplate = false;
1✔
337

338
    public triggerRect: DOMRect;
1✔
339

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

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

344
    private menuClassName: string;
49✔
345

49✔
346
    private columnClassName: string;
49✔
347

49✔
348
    private _menuColumnCls: any;
49✔
349

49✔
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
        return this.thyMultiple ? this.thyCascaderService.selectionModel.selected : this.thyCascaderService.selectionModel.selected[0];
49✔
364
    }
49✔
365
    private isMultiple = false;
49✔
366

49✔
367
    public menuMinWidth = 122;
49✔
368

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

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

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

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

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

4✔
389
    public get columns() {
4✔
390
        return this.thyCascaderService.columns;
4✔
391
    }
392

393
    private afterChangeFn: () => void;
9✔
394

9✔
395
    private resizeSubscription: Subscription;
9!
396

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

6✔
414
        this.thyCascaderService.cascaderValueChange().subscribe(options => {
415
            if (!options.isValueEqual) {
6✔
416
                this.onChangeFn(options.value);
6✔
417
                if (options.isSelectionModelEmpty) {
418
                    this.thyClear.emit();
419
                }
420
                this.thySelectionChange.emit(this.thyCascaderService.selectedOptions);
6✔
421
                this.thyChange.emit(options.value);
422
                if (this.afterChangeFn) {
423
                    this.afterChangeFn();
8✔
424
                    this.afterChangeFn = null;
8✔
425
                }
8✔
426
            }
427
        });
428
    }
1✔
429

1!
430
    ngAfterContentInit() {
×
431
        if (this.thyAutoExpand) {
×
432
            timer(0).subscribe(() => {
433
                this.cdr.markForCheck();
×
434
                this.setMenuVisible(true);
435
            });
1✔
436
        }
3✔
437
    }
438

1!
439
    ngOnChanges(changes: SimpleChanges): void {
×
440
        if (changes['thyIsOnlySelectLeaf']) {
×
441
            this.thyCascaderService.setCascaderOptions({ isOnlySelectLeaf: changes['thyIsOnlySelectLeaf'].currentValue });
×
442
        }
443
    }
×
444

×
445
    private initPosition() {
×
446
        const cascaderPosition: ConnectionPositionPair[] = EXPANDED_DROPDOWN_POSITIONS.map(item => {
×
447
            return { ...item };
448
        });
449
        cascaderPosition[0].offsetY = 4; // 左下
450
        cascaderPosition[1].offsetY = 4; // 右下
1✔
451
        cascaderPosition[2].offsetY = -4; // 右下
452
        cascaderPosition[3].offsetY = -4; // 右下
453
        this.positions = cascaderPosition;
454
    }
45✔
455

45✔
456
    writeValue(value: any): void {
45✔
457
        this.thyCascaderService.writeCascaderValue(value);
45✔
458
        if (this.isMultiple) {
56✔
459
            this.cdr.detectChanges();
460
        }
45✔
461
    }
462

18✔
463
    setDisabledState(isDisabled: boolean): void {
18✔
464
        this.disabled = isDisabled;
18✔
465
    }
11✔
466

467
    public positionChange(position: ConnectedOverlayPositionChange): void {
18✔
468
        const newValue = position.connectionPair.originY === 'bottom' ? 'bottom' : 'top';
469
        if (this.dropDownPosition !== newValue) {
470
            this.dropDownPosition = newValue;
471
            this.cdr.detectChanges();
472
        }
473
    }
102✔
474

36✔
475
    public isActivatedOption(option: ThyCascaderOption, index: number): boolean {
36✔
476
        return this.thyCascaderService.isActivatedOption(option, index);
477
    }
478

479
    public isHalfSelectedOption(option: ThyCascaderOption, index: number): boolean {
49✔
480
        return this.thyCascaderService.isHalfSelectedOption(option, index);
49✔
481
    }
49✔
482

483
    public isSelectedOption(option: ThyCascaderOption, index: number): boolean {
1✔
484
        return this.thyCascaderService.isSelectedOption(option, index);
485
    }
486

487
    public attached(): void {
488
        this.cdr.detectChanges();
489
        this.cdkConnectedOverlay.positionChange.pipe(take(1), takeUntil(this.destroy$)).subscribe(() => {
1✔
490
            this.scrollActiveElementIntoView();
491
        });
492
    }
493

494
    private scrollActiveElementIntoView() {
495
        if (!isEmpty(this.thyCascaderService.selectedOptions)) {
496
            const activeOptions = this.cascaderOptions
497
                .filter(item => item.nativeElement.classList.contains('thy-cascader-menu-item-active'))
498
                // for multiple mode
499
                .slice(-this.cascaderOptionContainers.length);
500

501
            this.cascaderOptionContainers.forEach((item, index) => {
502
                if (index <= activeOptions.length - 1) {
503
                    ScrollToService.scrollToElement(activeOptions[index].nativeElement, item.nativeElement);
504
                    this.cdr.detectChanges();
505
                }
506
            });
507
        }
508
    }
509

510
    public setMenuVisible(menuVisible: boolean): void {
511
        if (this.menuVisible !== menuVisible) {
512
            this.menuVisible = menuVisible;
513

514
            this.thyCascaderService.initActivatedOptions(this.menuVisible);
515
            this.setClassMap();
516
            this.setMenuClass();
517
            if (this.menuVisible) {
518
                this.triggerRect = this.trigger.nativeElement.getBoundingClientRect();
519
                this.subscribeTriggerResize();
520
            } else {
521
                this.unsubscribeTriggerResize();
522
            }
523
            this.thyExpandStatusChange.emit(menuVisible);
524
        }
525
    }
526

527
    public get menuCls(): any {
528
        return this._menuCls;
529
    }
1✔
530

531
    private setMenuClass(): void {
532
        this._menuCls = {
533
            [`${this.prefixCls}-menus`]: true,
1✔
534
            [`${this.prefixCls}-menus-hidden`]: !this.menuVisible,
535
            [`${this.thyMenuClassName}`]: this.thyMenuClassName,
536
            [`w-100`]: this.columns.length === 0
537
        };
1✔
538
    }
539

540
    public get menuColumnCls(): any {
541
        return this._menuColumnCls;
542
    }
1✔
543

544
    private setMenuColumnClass(): void {
545
        this._menuColumnCls = {
546
            [`${this.prefixCls}-menu`]: true,
1✔
547
            [`${this.thyColumnClassName}`]: this.thyColumnClassName
548
        };
549
    }
550

1✔
551
    public get labelCls(): any {
552
        return this._labelCls;
553
    }
554

1✔
555
    private setLabelClass(): void {
556
        this._labelCls = {
557
            [`${this.prefixCls}-picker-label`]: true,
558
            [`${this.prefixCls}-show-search`]: false,
1✔
559
            [`${this.prefixCls}-focused`]: false,
560
            'text-truncate': true
561
        };
562
    }
563

564
    private setClassMap(): void {
565
        const classMap = {
49✔
566
            [`${this.prefixCls}`]: true,
567
            [`${this.prefixCls}-picker`]: true,
568
            [`${this.prefixCls}-${this.thySize}`]: true,
569
            [`${this.prefixCls}-picker-disabled`]: this.disabled,
570
            [`${this.prefixCls}-picker-open`]: this.menuVisible
571
        };
572
        this.hostRenderer.updateClassByMap(classMap);
573
    }
574

575
    private isClickTriggerAction(): boolean {
576
        if (typeof this.thyTriggerAction === 'string') {
577
            return this.thyTriggerAction === 'click';
578
        }
579
        return this.thyTriggerAction.indexOf('click') !== -1;
580
    }
581

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

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

596
    @HostListener('click', ['$event'])
597
    public toggleClick($event: Event) {
598
        if (this.disabled) {
599
            return;
600
        }
601
        if (this.isClickTriggerAction()) {
602
            this.setMenuVisible(!this.menuVisible);
603
        }
604
    }
605

606
    @HostListener('mouseover', ['$event'])
607
    public toggleHover($event: Event) {
608
        if (this.disabled) {
609
            return;
610
        }
611
        if (this.isHoverTriggerAction()) {
612
            this.setMenuVisible(!this.menuVisible);
613
        }
614
    }
615

616
    public clickOption(option: ThyCascaderOption, index: number, event: Event | boolean): void {
617
        this.thyCascaderService.clickOption(option, index, event, this.selectOption);
618
    }
619

620
    public mouseoverOption(option: ThyCascaderOption, index: number, event: Event): void {
621
        if (event) {
622
            event.preventDefault();
623
        }
624

625
        if (option && option.disabled && !this.isMultiple) {
626
            return;
627
        }
628

629
        if (!this.isHoverExpandTriggerAction() && !(option && option.disabled && this.isMultiple)) {
630
            return;
631
        }
632
        this.setActiveOption(option, index, false);
633
    }
634

635
    public mouseleaveMenu(event: Event) {
636
        if (event) {
637
            event.preventDefault();
638
        }
639
        if (!this.isHoverTriggerAction()) {
640
            return;
641
        }
642
        this.setMenuVisible(!this.menuVisible);
643
    }
644

645
    onBlur(event?: FocusEvent) {
646
        // Tab 聚焦后自动聚焦到 input 输入框,此分支下直接返回,无需触发 onTouchedFn
647
        if (elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-cascader-menus', 'thy-cascader'])) {
648
            return;
649
        }
650
        this.onTouchedFn();
651
    }
652

653
    onFocus(event?: FocusEvent) {
654
        if (!elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-cascader-menus', 'thy-cascader'])) {
655
            const inputElement: HTMLInputElement = this.elementRef.nativeElement.querySelector('input');
656
            inputElement.focus();
657
        }
658
    }
659

660
    public closeMenu(): void {
661
        if (this.menuVisible) {
662
            this.setMenuVisible(false);
663
            this.onTouchedFn();
664
            this.isShowSearchPanel = false;
665
            this.thyCascaderService.searchResultList = [];
666
        }
667
    }
668

669
    public setActiveOption(option: ThyCascaderOption, index: number, select: boolean, loadChildren: boolean = true): void {
670
        this.thyCascaderService.setActiveOption(option, index, select, loadChildren, this.selectOption);
671
    }
672

673
    private selectOption = (option: ThyCascaderOption, index: number) => {
674
        if ((option.isLeaf || !this.thyIsOnlySelectLeaf) && !this.thyMultiple) {
675
            this.afterChangeFn = () => {
676
                this.setMenuVisible(false);
677
                this.onTouchedFn();
678
            };
679
        }
680
        this.thySelect.emit({ option, index });
681
        const isOptionCanSelect = this.thyChangeOnSelect && !this.isMultiple;
682
        if (option.isLeaf || !this.thyIsOnlySelectLeaf || isOptionCanSelect || this.shouldPerformSelection(option, index)) {
683
            this.thyCascaderService.selectOption(option, index);
684
        }
685
    };
686

687
    public removeSelectedItem(event: { item: SelectOptionBase; $eventOrigin: Event }) {
688
        event.$eventOrigin.stopPropagation();
689
        this.thyCascaderService.removeSelectedItem(event?.item);
690
    }
691

692
    private shouldPerformSelection(option: ThyCascaderOption, level: number): boolean {
693
        return typeof this.thyChangeOn === 'function' ? this.thyChangeOn(option, level) === true : false;
694
    }
695

696
    public clearSelection($event: Event): void {
697
        if ($event) {
698
            $event.stopPropagation();
699
            $event.preventDefault();
700
        }
701
        this.afterChangeFn = () => {
702
            this.setMenuVisible(false);
703
        };
704
        this.thyCascaderService.clearSelection();
705
    }
706

707
    constructor(
708
        private cdr: ChangeDetectorRef,
709
        public elementRef: ElementRef,
710
        private ngZone: NgZone,
711
        public thyCascaderService: ThyCascaderService
712
    ) {
713
        super();
714
    }
715

716
    public trackByFn(index: number, item: ThyCascaderOption) {
717
        return item?.value || item?._id || index;
718
    }
719

720
    public searchFilter(searchText: string) {
721
        if (!searchText && !this.isSelectingSearchState) {
722
            this.resetSearch();
723
        }
724
        this.searchText$.next(searchText);
725
    }
726

727
    private initSearch() {
728
        this.searchText$
729
            .pipe(
730
                takeUntil(this.destroy$),
731
                debounceTime(200),
732
                distinctUntilChanged(),
733
                filter(text => text !== '')
734
            )
735
            .subscribe(searchText => {
736
                this.resetSearch();
737

738
                // local search
739
                this.searchInLocal(searchText);
740
                this.isShowSearchPanel = true;
741
            });
742
    }
743

744
    private searchInLocal(searchText: string): void {
745
        this.thyCascaderService.searchInLocal(searchText);
746
    }
747

748
    private resetSearch() {
749
        this.isShowSearchPanel = false;
750
        this.thyCascaderService.resetSearch();
751
        this.scrollActiveElementIntoView();
752
    }
753

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

780
    private subscribeTriggerResize(): void {
781
        this.unsubscribeTriggerResize();
782
        this.ngZone.runOutsideAngular(() => {
783
            this.resizeSubscription = new Observable(observer => {
784
                const resize = new ResizeObserver((entries: ResizeObserverEntry[]) => {
785
                    observer.next();
786
                });
787
                resize.observe(this.trigger.nativeElement);
788
            }).subscribe(() => {
789
                this.ngZone.run(() => {
790
                    this.triggerRect = this.trigger.nativeElement.getBoundingClientRect();
791
                    if (this.cdkConnectedOverlay && this.cdkConnectedOverlay.overlayRef) {
792
                        this.cdkConnectedOverlay.overlayRef.updatePosition();
793
                    }
794
                    this.cdr.markForCheck();
795
                });
796
            });
797
        });
798
    }
799

800
    private unsubscribeTriggerResize(): void {
801
        if (this.resizeSubscription) {
802
            this.resizeSubscription.unsubscribe();
803
            this.resizeSubscription = null;
804
        }
805
    }
806

807
    ngOnDestroy() {
808
        this.unsubscribeTriggerResize();
809
        this.destroy$.next();
810
        this.destroy$.complete();
811
    }
812
}
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